From 97ba7500dc911afcf4e29640e60a356077742a48 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 31 Dec 2025 17:47:32 +0100 Subject: [PATCH 1/4] unified gateway interface --- .../internal/server/internal/serverimp.go | 12 +- .../fees/internal/service/fees/service.go | 28 + api/fx/ingestor/internal/app/app.go | 21 + api/fx/ingestor/internal/config/config.go | 10 +- .../internal/server/internal/serverimp.go | 14 +- .../oracle/internal/service/oracle/service.go | 35 +- api/gateway/chain/client/rail_gateway.go | 258 +++++++ api/gateway/chain/go.mod | 2 +- api/gateway/chain/go.sum | 4 +- .../internal/server/internal/serverimp.go | 9 +- .../chain/internal/service/gateway/service.go | 41 + api/gateway/mntx/README.md | 6 +- api/gateway/mntx/client/client.go | 7 + api/gateway/mntx/client/fake.go | 8 + api/gateway/mntx/config.yml | 8 + api/gateway/mntx/go.mod | 2 +- api/gateway/mntx/go.sum | 4 +- .../internal/server/internal/serverimp.go | 176 ++++- .../internal/service/gateway/instances.go | 63 ++ .../mntx/internal/service/gateway/metrics.go | 100 --- .../mntx/internal/service/gateway/options.go | 10 + .../internal/service/gateway/payout_get.go | 36 - .../internal/service/gateway/payout_store.go | 46 -- .../internal/service/gateway/payout_submit.go | 144 ---- .../service/gateway/payout_validation.go | 106 --- .../mntx/internal/service/gateway/service.go | 92 ++- api/ledger/client/client.go | 134 ++++ api/ledger/client/fake.go | 30 +- api/ledger/internal/service/ledger/service.go | 29 +- api/notification/go.mod | 2 +- api/notification/go.sum | 4 +- .../server/notificationimp/notification.go | 22 +- api/payments/orchestrator/config.yml | 12 + .../internal/server/internal/serverimp.go | 291 +++++++- .../service/orchestrator/card_payout.go | 702 ------------------ .../orchestrator/card_payout_constants.go | 10 + .../orchestrator/card_payout_funding.go | 353 +++++++++ .../orchestrator/card_payout_helpers.go | 80 ++ .../orchestrator/card_payout_routes.go | 29 + .../orchestrator/card_payout_submit.go | 262 +++++++ .../service/orchestrator/card_payout_test.go | 63 +- .../composite_gateway_registry.go | 64 ++ .../internal/service/orchestrator/convert.go | 539 ++++++++++++-- .../orchestrator/convert_types_test.go | 114 +++ .../discovery_gateway_registry.go | 131 ++++ .../service/orchestrator/execution_plan.go | 168 +++++ .../service/orchestrator/gateway_registry.go | 249 +++++++ .../service/orchestrator/handlers_events.go | 152 +++- .../internal/service/orchestrator/helpers.go | 60 +- .../service/orchestrator/helpers_test.go | 4 +- .../service/orchestrator/model_money.go | 13 + .../internal/service/orchestrator/options.go | 162 +++- .../service/orchestrator/payment_executor.go | 247 +----- .../service/orchestrator/payment_plan_card.go | 170 +++++ .../orchestrator/payment_plan_chain.go | 106 +++ .../orchestrator/payment_plan_executor.go | 94 +++ .../payment_plan_executor_test.go | 184 +++++ .../orchestrator/payment_plan_helpers.go | 165 ++++ .../orchestrator/payment_plan_ledger.go | 220 ++++++ .../orchestrator/payment_plan_steps.go | 141 ++++ .../service/orchestrator/plan_builder.go | 23 + .../orchestrator/plan_builder_default.go | 51 ++ .../orchestrator/plan_builder_default_test.go | 202 +++++ .../orchestrator/plan_builder_endpoints.go | 135 ++++ .../orchestrator/plan_builder_gateways.go | 213 ++++++ .../orchestrator/plan_builder_plans.go | 90 +++ .../orchestrator/plan_builder_routes.go | 149 ++++ .../orchestrator/plan_builder_steps.go | 257 +++++++ .../service/orchestrator/quote_engine.go | 16 +- .../orchestrator/rail_gateway_fake_test.go | 41 + .../internal/service/orchestrator/service.go | 15 +- .../orchestrator/service_helpers_test.go | 46 +- .../service/orchestrator/service_test.go | 254 ++++++- .../orchestrator/storage/model/payment.go | 195 +++-- .../orchestrator/storage/model/route.go | 47 ++ .../orchestrator/storage/mongo/repository.go | 19 +- .../storage/mongo/store/payments.go | 9 +- .../storage/mongo/store/routes.go | 165 ++++ api/payments/orchestrator/storage/storage.go | 13 + api/pkg/discovery/announcer.go | 157 ++++ api/pkg/discovery/client.go | 137 ++++ api/pkg/discovery/events.go | 31 + api/pkg/discovery/lookup.go | 69 ++ api/pkg/discovery/messages.go | 56 ++ api/pkg/discovery/registry.go | 258 +++++++ api/pkg/discovery/service.go | 188 +++++ api/pkg/discovery/subjects.go | 10 + api/pkg/discovery/types.go | 40 + api/pkg/messaging/consumer/consumer.go | 66 ++ api/pkg/model/notification/notification.go | 7 + api/pkg/model/notificationevent.go | 14 +- api/pkg/mservice/services.go | 6 +- api/pkg/payments/rail/gateway.go | 83 +++ api/pkg/payments/rail/ledger.go | 38 + api/pkg/payments/types/chain.go | 49 ++ api/pkg/payments/types/fees.go | 94 +++ api/pkg/payments/types/fx.go | 107 +++ api/pkg/payments/types/money.go | 33 + api/proto/common/gateway/v1/gateway.proto | 62 ++ api/proto/gateway/mntx/v1/mntx.proto | 80 +- .../orchestrator/v1/orchestrator.proto | 37 +- .../server/paymentapiimp/discovery.go | 95 +++ .../internal/server/paymentapiimp/mapper.go | 8 +- .../internal/server/paymentapiimp/service.go | 57 +- 104 files changed, 8228 insertions(+), 1742 deletions(-) create mode 100644 api/gateway/chain/client/rail_gateway.go create mode 100644 api/gateway/mntx/internal/service/gateway/instances.go delete mode 100644 api/gateway/mntx/internal/service/gateway/payout_get.go delete mode 100644 api/gateway/mntx/internal/service/gateway/payout_store.go delete mode 100644 api/gateway/mntx/internal/service/gateway/payout_submit.go delete mode 100644 api/gateway/mntx/internal/service/gateway/payout_validation.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/execution_plan.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/model_money.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go create mode 100644 api/payments/orchestrator/storage/model/route.go create mode 100644 api/payments/orchestrator/storage/mongo/store/routes.go create mode 100644 api/pkg/discovery/announcer.go create mode 100644 api/pkg/discovery/client.go create mode 100644 api/pkg/discovery/events.go create mode 100644 api/pkg/discovery/lookup.go create mode 100644 api/pkg/discovery/messages.go create mode 100644 api/pkg/discovery/registry.go create mode 100644 api/pkg/discovery/service.go create mode 100644 api/pkg/discovery/subjects.go create mode 100644 api/pkg/discovery/types.go create mode 100644 api/pkg/messaging/consumer/consumer.go create mode 100644 api/pkg/payments/rail/gateway.go create mode 100644 api/pkg/payments/rail/ledger.go create mode 100644 api/pkg/payments/types/chain.go create mode 100644 api/pkg/payments/types/fees.go create mode 100644 api/pkg/payments/types/fx.go create mode 100644 api/pkg/payments/types/money.go create mode 100644 api/server/internal/server/paymentapiimp/discovery.go 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 +} -- 2.49.1 From ea1c69f14a1f473675852cabe1e8ddb7068965f6 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 2 Jan 2026 02:44:01 +0100 Subject: [PATCH 2/4] discovery service --- .woodpecker/discovery.yml | 72 +++ api/discovery/.gitignore | 3 + api/discovery/config.yml | 17 + api/discovery/go.mod | 51 ++ api/discovery/go.sum | 225 +++++++ api/discovery/internal/appversion/version.go | 27 + .../internal/server/internal/config.go | 47 ++ .../internal/server/internal/discovery.go | 58 ++ .../internal/server/internal/metrics.go | 85 +++ .../internal/server/internal/serverimp.go | 109 ++++ .../internal/server/internal/types.go | 28 + api/discovery/internal/server/server.go | 11 + api/discovery/main.go | 17 + api/fx/ingestor/config.yml | 12 + api/fx/ingestor/main.go | 2 + api/notification/config.yml | 1 + .../internal/server/internal/builders.go | 224 +++++++ .../internal/server/internal/clients.go | 150 +++++ .../internal/server/internal/config.go | 135 ++++ .../internal/server/internal/dependencies.go | 84 +++ .../internal/server/internal/discovery.go | 50 ++ .../internal/server/internal/lifecycle.go | 18 + .../internal/server/internal/serverimp.go | 603 +----------------- .../internal/server/internal/types.go | 30 + .../discovery_gateway_registry.go | 1 + .../orchestrator/storage/model/payment.go | 1 + api/pkg/discovery/announcer.go | 43 +- api/pkg/discovery/client.go | 15 +- api/pkg/discovery/instanceid.go | 27 + api/pkg/discovery/instanceid_test.go | 53 ++ api/pkg/discovery/keys.go | 99 +++ api/pkg/discovery/kv.go | 103 +++ api/pkg/discovery/logging.go | 108 ++++ api/pkg/discovery/lookup.go | 30 +- api/pkg/discovery/registry.go | 208 +++++- api/pkg/discovery/service.go | 186 +++++- api/pkg/discovery/types.go | 17 +- api/pkg/discovery/watcher.go | 126 ++++ api/pkg/messaging/internal/natsb/broker.go | 55 +- api/pkg/server/internal/server.go | 2 + api/server/config.yml | 3 +- ci/prod/.env.runtime | 6 + ci/prod/compose/discovery.dockerfile | 40 ++ ci/prod/compose/discovery.yml | 37 ++ ci/prod/scripts/deploy/discovery.sh | 139 ++++ ci/scripts/discovery/build-image.sh | 85 +++ ci/scripts/discovery/deploy.sh | 57 ++ 47 files changed, 2799 insertions(+), 701 deletions(-) create mode 100644 .woodpecker/discovery.yml create mode 100644 api/discovery/.gitignore create mode 100644 api/discovery/config.yml create mode 100644 api/discovery/go.mod create mode 100644 api/discovery/go.sum create mode 100644 api/discovery/internal/appversion/version.go create mode 100644 api/discovery/internal/server/internal/config.go create mode 100644 api/discovery/internal/server/internal/discovery.go create mode 100644 api/discovery/internal/server/internal/metrics.go create mode 100644 api/discovery/internal/server/internal/serverimp.go create mode 100644 api/discovery/internal/server/internal/types.go create mode 100644 api/discovery/internal/server/server.go create mode 100644 api/discovery/main.go create mode 100644 api/payments/orchestrator/internal/server/internal/builders.go create mode 100644 api/payments/orchestrator/internal/server/internal/clients.go create mode 100644 api/payments/orchestrator/internal/server/internal/config.go create mode 100644 api/payments/orchestrator/internal/server/internal/dependencies.go create mode 100644 api/payments/orchestrator/internal/server/internal/discovery.go create mode 100644 api/payments/orchestrator/internal/server/internal/lifecycle.go create mode 100644 api/payments/orchestrator/internal/server/internal/types.go create mode 100644 api/pkg/discovery/instanceid.go create mode 100644 api/pkg/discovery/instanceid_test.go create mode 100644 api/pkg/discovery/keys.go create mode 100644 api/pkg/discovery/kv.go create mode 100644 api/pkg/discovery/logging.go create mode 100644 api/pkg/discovery/watcher.go create mode 100644 ci/prod/compose/discovery.dockerfile create mode 100644 ci/prod/compose/discovery.yml create mode 100644 ci/prod/scripts/deploy/discovery.sh create mode 100644 ci/scripts/discovery/build-image.sh create mode 100644 ci/scripts/discovery/deploy.sh diff --git a/.woodpecker/discovery.yml b/.woodpecker/discovery.yml new file mode 100644 index 0000000..18b69d8 --- /dev/null +++ b/.woodpecker/discovery.yml @@ -0,0 +1,72 @@ +matrix: + include: + - DISCOVERY_IMAGE_PATH: discovery/service + DISCOVERY_DOCKERFILE: ci/prod/compose/discovery.dockerfile + DISCOVERY_ENV: prod + +when: + - event: push + branch: main + +steps: + - name: version + image: alpine:latest + commands: + - set -euo pipefail 2>/dev/null || set -eu + - apk add --no-cache git + - GIT_REV="$(git rev-parse --short HEAD)" + - BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + - APP_V="$(cat version)" + - BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + - BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}" + - printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \ + "$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version + + - name: proto + image: golang:alpine + depends_on: [ version ] + commands: + - set -eu + - apk add --no-cache bash git build-base protoc protobuf-dev + - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + - export PATH="$(go env GOPATH)/bin:$PATH" + - bash ci/scripts/proto/generate.sh + + - name: secrets + image: alpine:latest + depends_on: [ version ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash coreutils openssh-keygen curl sed python3 + - mkdir -p secrets + - ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600 + - base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY + - chmod 600 secrets/SSH_KEY + - ssh-keygen -y -f secrets/SSH_KEY >/dev/null + - ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER + - ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD + + - name: build-image + image: gcr.io/kaniko-project/executor:debug + depends_on: [ proto, secrets ] + commands: + - sh ci/scripts/discovery/build-image.sh + + - name: deploy + image: alpine:latest + depends_on: [ secrets, build-image ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash openssh-client rsync coreutils curl sed python3 + - mkdir -p /root/.ssh + - install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa + - sh ci/scripts/discovery/deploy.sh diff --git a/api/discovery/.gitignore b/api/discovery/.gitignore new file mode 100644 index 0000000..c62beb6 --- /dev/null +++ b/api/discovery/.gitignore @@ -0,0 +1,3 @@ +internal/generated +.gocache +app diff --git a/api/discovery/config.yml b/api/discovery/config.yml new file mode 100644 index 0000000..a022be6 --- /dev/null +++ b/api/discovery/config.yml @@ -0,0 +1,17 @@ +runtime: + shutdown_timeout_seconds: 15 + +metrics: + address: ":9405" + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Discovery Service + max_reconnects: 10 + reconnect_wait: 5 diff --git a/api/discovery/go.mod b/api/discovery/go.mod new file mode 100644 index 0000000..76c6541 --- /dev/null +++ b/api/discovery/go.mod @@ -0,0 +1,51 @@ +module github.com/tech/sendico/discovery + +go 1.25.3 + +replace github.com/tech/sendico/pkg => ../pkg + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/prometheus/client_golang v1.23.2 + github.com/tech/sendico/pkg v0.1.0 + go.uber.org/zap v1.27.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + 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/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nkeys v0.4.12 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver v1.17.6 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/api/discovery/go.sum b/api/discovery/go.sum new file mode 100644 index 0000000..fbbbd30 --- /dev/null +++ b/api/discovery/go.sum @@ -0,0 +1,225 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +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= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E= +github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +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/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= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= +github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/discovery/internal/appversion/version.go b/api/discovery/internal/appversion/version.go new file mode 100644 index 0000000..57e404b --- /dev/null +++ b/api/discovery/internal/appversion/version.go @@ -0,0 +1,27 @@ +package appversion + +import ( + "github.com/tech/sendico/pkg/version" + vf "github.com/tech/sendico/pkg/version/factory" +) + +// Build information. Populated at build-time. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +func Create() version.Printer { + vi := version.Info{ + Program: "Sendico Discovery Service", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + return vf.Create(&vi) +} diff --git a/api/discovery/internal/server/internal/config.go b/api/discovery/internal/server/internal/config.go new file mode 100644 index 0000000..60eaceb --- /dev/null +++ b/api/discovery/internal/server/internal/config.go @@ -0,0 +1,47 @@ +package serverimp + +import ( + "os" + "strings" + + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +const defaultMetricsAddress = ":9405" + +type config struct { + Runtime *grpcapp.RuntimeConfig `yaml:"runtime"` + Messaging *msg.Config `yaml:"messaging"` + Metrics *metricsConfig `yaml:"metrics"` +} + +type metricsConfig struct { + Address string `yaml:"address"` +} + +func (i *Imp) loadConfig() (*config, error) { + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err + } + + cfg := &config{} + if err := yaml.Unmarshal(data, cfg); err != nil { + i.logger.Error("Failed to parse configuration", zap.Error(err)) + return nil, err + } + + if cfg.Runtime == nil { + cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} + } + + if cfg.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" { + cfg.Metrics.Address = defaultMetricsAddress + } + + return cfg, nil +} diff --git a/api/discovery/internal/server/internal/discovery.go b/api/discovery/internal/server/internal/discovery.go new file mode 100644 index 0000000..eee5f76 --- /dev/null +++ b/api/discovery/internal/server/internal/discovery.go @@ -0,0 +1,58 @@ +package serverimp + +import ( + "github.com/tech/sendico/discovery/internal/appversion" + "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/mservice" + "go.uber.org/zap" +) + +func (i *Imp) startDiscovery(cfg *config) error { + if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" { + return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging") + } + + broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging) + if err != nil { + return err + } + i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver))) + producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker) + + registry := discovery.NewRegistry() + svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery)) + if err != nil { + return err + } + svc.Start() + i.registrySvc = svc + + announce := discovery.Announcement{ + Service: "DISCOVERY", + InstanceID: discovery.InstanceID(), + Operations: []string{"discovery.lookup"}, + Version: appversion.Create().Short(), + } + i.announcer = discovery.NewAnnouncer(i.logger, producer, string(mservice.Discovery), announce) + i.announcer.Start() + + i.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver))) + return nil +} + +func (i *Imp) stopDiscovery() { + if i == nil { + return + } + if i.announcer != nil { + i.announcer.Stop() + i.announcer = nil + } + if i.registrySvc != nil { + i.registrySvc.Stop() + i.registrySvc = nil + } +} diff --git a/api/discovery/internal/server/internal/metrics.go b/api/discovery/internal/server/internal/metrics.go new file mode 100644 index 0000000..a4cfa45 --- /dev/null +++ b/api/discovery/internal/server/internal/metrics.go @@ -0,0 +1,85 @@ +package serverimp + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/api/routers/health" + "go.uber.org/zap" +) + +func (i *Imp) startMetrics(cfg *metricsConfig) { + if i == nil { + return + } + address := "" + if cfg != nil { + address = strings.TrimSpace(cfg.Address) + } + if address == "" { + i.logger.Info("Metrics endpoint disabled") + return + } + + listener, err := net.Listen("tcp", address) + if err != nil { + i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err)) + return + } + + router := chi.NewRouter() + router.Handle("/metrics", promhttp.Handler()) + + var healthRouter routers.Health + if hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, ""); err != nil { + i.logger.Warn("Failed to initialise health router", zap.Error(err)) + } else { + hr.SetStatus(health.SSStarting) + healthRouter = hr + } + + i.metricsHealth = healthRouter + i.metricsSrv = &http.Server{ + Addr: address, + Handler: router, + ReadHeaderTimeout: 5 * time.Second, + } + + if healthRouter != nil { + healthRouter.SetStatus(health.SSRunning) + } + + go func() { + i.logger.Info("Prometheus endpoint listening", zap.String("address", address)) + if err := i.metricsSrv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err)) + if healthRouter != nil { + healthRouter.SetStatus(health.SSTerminating) + } + } + }() +} + +func (i *Imp) shutdownMetrics(ctx context.Context) { + if i.metricsHealth != nil { + i.metricsHealth.SetStatus(health.SSTerminating) + i.metricsHealth.Finish() + i.metricsHealth = nil + } + if i.metricsSrv == nil { + return + } + if err := i.metricsSrv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + i.logger.Warn("Failed to stop metrics server", zap.Error(err)) + } else { + i.logger.Info("Metrics server stopped") + } + i.metricsSrv = nil +} diff --git a/api/discovery/internal/server/internal/serverimp.go b/api/discovery/internal/server/internal/serverimp.go new file mode 100644 index 0000000..7806d87 --- /dev/null +++ b/api/discovery/internal/server/internal/serverimp.go @@ -0,0 +1,109 @@ +package serverimp + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { + return &Imp{ + logger: logger.Named("server"), + file: file, + debug: debug, + }, nil +} + +func (i *Imp) Start() error { + i.initStopChannels() + defer i.closeDone() + + i.logger.Info("Starting discovery service", zap.String("config_file", i.file), zap.Bool("debug", i.debug)) + + cfg, err := i.loadConfig() + if err != nil { + return err + } + i.config = cfg + + messagingDriver := "none" + if cfg.Messaging != nil { + messagingDriver = string(cfg.Messaging.Driver) + } + metricsAddress := "" + if cfg.Metrics != nil { + metricsAddress = strings.TrimSpace(cfg.Metrics.Address) + } + if metricsAddress == "" { + metricsAddress = "disabled" + } + i.logger.Info("Discovery config loaded", zap.String("messaging_driver", messagingDriver), zap.String("metrics_address", metricsAddress)) + + i.startMetrics(cfg.Metrics) + + if err := i.startDiscovery(cfg); err != nil { + i.stopDiscovery() + ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout()) + i.shutdownMetrics(ctx) + cancel() + return err + } + + i.logger.Info("Discovery service ready", zap.String("messaging_driver", messagingDriver)) + + <-i.stopCh + i.logger.Info("Discovery service stop signal received") + return nil +} + +func (i *Imp) Shutdown() { + timeout := i.shutdownTimeout() + i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout)) + + i.stopDiscovery() + i.signalStop() + + if i.doneCh != nil { + <-i.doneCh + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + i.shutdownMetrics(ctx) + cancel() + + i.logger.Info("Discovery service stopped") +} + +func (i *Imp) initStopChannels() { + if i.stopCh == nil { + i.stopCh = make(chan struct{}) + } + if i.doneCh == nil { + i.doneCh = make(chan struct{}) + } +} + +func (i *Imp) signalStop() { + i.stopOnce.Do(func() { + if i.stopCh != nil { + close(i.stopCh) + } + }) +} + +func (i *Imp) closeDone() { + i.doneOnce.Do(func() { + if i.doneCh != nil { + close(i.doneCh) + } + }) +} + +func (i *Imp) shutdownTimeout() time.Duration { + if i.config != nil && i.config.Runtime != nil { + return i.config.Runtime.ShutdownTimeout() + } + return 15 * time.Second +} diff --git a/api/discovery/internal/server/internal/types.go b/api/discovery/internal/server/internal/types.go new file mode 100644 index 0000000..171d338 --- /dev/null +++ b/api/discovery/internal/server/internal/types.go @@ -0,0 +1,28 @@ +package serverimp + +import ( + "net/http" + "sync" + + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/mlogger" +) + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config + registrySvc *discovery.RegistryService + announcer *discovery.Announcer + + metricsSrv *http.Server + metricsHealth routers.Health + + stopOnce sync.Once + doneOnce sync.Once + stopCh chan struct{} + doneCh chan struct{} +} diff --git a/api/discovery/internal/server/server.go b/api/discovery/internal/server/server.go new file mode 100644 index 0000000..cf24592 --- /dev/null +++ b/api/discovery/internal/server/server.go @@ -0,0 +1,11 @@ +package server + +import ( + serverimp "github.com/tech/sendico/discovery/internal/server/internal" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" +) + +func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return serverimp.Create(logger, file, debug) +} diff --git a/api/discovery/main.go b/api/discovery/main.go new file mode 100644 index 0000000..16d1242 --- /dev/null +++ b/api/discovery/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/discovery/internal/appversion" + si "github.com/tech/sendico/discovery/internal/server" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" +) + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("main", appversion.Create(), factory) +} diff --git a/api/fx/ingestor/config.yml b/api/fx/ingestor/config.yml index f9b67dc..4f9459f 100644 --- a/api/fx/ingestor/config.yml +++ b/api/fx/ingestor/config.yml @@ -49,6 +49,18 @@ metrics: enabled: true address: ":9102" +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: FX Ingestor + max_reconnects: 10 + reconnect_wait: 5 + database: driver: mongodb settings: diff --git a/api/fx/ingestor/main.go b/api/fx/ingestor/main.go index ce51c38..7f47708 100644 --- a/api/fx/ingestor/main.go +++ b/api/fx/ingestor/main.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/fx/ingestor/internal/app" "github.com/tech/sendico/fx/ingestor/internal/appversion" "github.com/tech/sendico/fx/ingestor/internal/signalctx" + "github.com/tech/sendico/pkg/discovery" lf "github.com/tech/sendico/pkg/mlogger/factory" "go.uber.org/zap" ) @@ -25,6 +26,7 @@ func main() { flag.Parse() logger := lf.NewLogger(*debugFlag).Named("fx_ingestor") + logger = logger.With(zap.String("instance_id", discovery.InstanceID())) defer logger.Sync() av := appversion.Create() diff --git a/api/notification/config.yml b/api/notification/config.yml index 2277682..1c3a9b3 100755 --- a/api/notification/config.yml +++ b/api/notification/config.yml @@ -36,6 +36,7 @@ api: message_broker: driver: NATS settings: + url_env: NATS_URL host_env: NATS_HOST port_env: NATS_PORT username_env: NATS_USER diff --git a/api/payments/orchestrator/internal/server/internal/builders.go b/api/payments/orchestrator/internal/server/internal/builders.go new file mode 100644 index 0000000..b2f8858 --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/builders.go @@ -0,0 +1,224 @@ +package serverimp + +import ( + "strings" + + chainclient "github.com/tech/sendico/gateway/chain/client" + mntxclient "github.com/tech/sendico/gateway/mntx/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/payments/rail" + "go.uber.org/zap" +) + +func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute { + if len(src) == 0 { + return nil + } + result := make(map[string]orchestrator.CardGatewayRoute, len(src)) + for key, route := range src { + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + continue + } + result[trimmedKey] = orchestrator.CardGatewayRoute{ + FundingAddress: strings.TrimSpace(route.FundingAddress), + FeeAddress: strings.TrimSpace(route.FeeAddress), + FeeWalletRef: strings.TrimSpace(route.FeeWalletRef), + } + } + return result +} + +func buildFeeLedgerAccounts(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + result := make(map[string]string, len(src)) + for key, account := range src { + k := strings.ToLower(strings.TrimSpace(key)) + v := strings.TrimSpace(account) + if k == "" || v == "" { + continue + } + result[k] = v + } + 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/server/internal/clients.go b/api/payments/orchestrator/internal/server/internal/clients.go new file mode 100644 index 0000000..96c122e --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/clients.go @@ -0,0 +1,150 @@ +package serverimp + +import ( + "context" + "crypto/tls" + + oracleclient "github.com/tech/sendico/fx/oracle/client" + chainclient "github.com/tech/sendico/gateway/chain/client" + mntxclient "github.com/tech/sendico/gateway/mntx/client" + ledgerclient "github.com/tech/sendico/ledger/client" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.ClientConn) { + addr := cfg.address() + if addr == "" { + return nil, nil + } + + dialCtx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) + defer cancel() + + creds := credentials.NewTLS(&tls.Config{}) + if cfg.InsecureTransport { + creds = insecure.NewCredentials() + } + + conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds)) + if err != nil { + i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err)) + return nil, nil + } + + i.logger.Info("Connected to fees service", zap.String("address", addr)) + return feesv1.NewFeeEngineClient(conn), conn +} + +func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client { + addr := cfg.address() + if addr == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) + defer cancel() + + client, err := ledgerclient.New(ctx, ledgerclient.Config{ + Address: addr, + DialTimeout: cfg.dialTimeout(), + CallTimeout: cfg.callTimeout(), + Insecure: cfg.InsecureTransport, + }) + if err != nil { + i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err)) + return nil + } + i.logger.Info("Connected to ledger service", zap.String("address", addr)) + return client +} + +func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client { + addr := cfg.address() + if addr == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) + defer cancel() + + client, err := chainclient.New(ctx, chainclient.Config{ + Address: addr, + DialTimeout: cfg.dialTimeout(), + CallTimeout: cfg.callTimeout(), + Insecure: cfg.InsecureTransport, + }) + if err != nil { + i.logger.Warn("failed to connect to chain gateway service", zap.String("address", addr), zap.Error(err)) + return nil + } + i.logger.Info("connected to chain gateway service", zap.String("address", addr)) + return client +} + +func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client { + addr := cfg.address() + if addr == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) + defer cancel() + + client, err := mntxclient.New(ctx, mntxclient.Config{ + Address: addr, + DialTimeout: cfg.dialTimeout(), + CallTimeout: cfg.callTimeout(), + Logger: i.logger.Named("client.mntx"), + }) + if err != nil { + i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err)) + return nil + } + i.logger.Info("Connected to mntx gateway service", zap.String("address", addr)) + return client +} + +func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client { + addr := cfg.address() + if addr == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) + defer cancel() + + client, err := oracleclient.New(ctx, oracleclient.Config{ + Address: addr, + DialTimeout: cfg.dialTimeout(), + CallTimeout: cfg.callTimeout(), + Insecure: cfg.InsecureTransport, + }) + if err != nil { + i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err)) + return nil + } + i.logger.Info("Connected to oracle service", zap.String("address", addr)) + return client +} + +func (i *Imp) closeClients() { + if i.ledgerClient != nil { + _ = i.ledgerClient.Close() + } + if i.gatewayClient != nil { + _ = i.gatewayClient.Close() + } + if i.mntxClient != nil { + _ = i.mntxClient.Close() + } + if i.oracleClient != nil { + _ = i.oracleClient.Close() + } + if i.feesConn != nil { + _ = i.feesConn.Close() + } +} diff --git a/api/payments/orchestrator/internal/server/internal/config.go b/api/payments/orchestrator/internal/server/internal/config.go new file mode 100644 index 0000000..8ae52aa --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/config.go @@ -0,0 +1,135 @@ +package serverimp + +import ( + "os" + "strings" + "time" + + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +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"` + GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"` +} + +type clientConfig struct { + Address string `yaml:"address"` + DialTimeoutSecs int `yaml:"dial_timeout_seconds"` + CallTimeoutSecs int `yaml:"call_timeout_seconds"` + InsecureTransport bool `yaml:"insecure"` +} + +type cardGatewayRouteConfig struct { + FundingAddress string `yaml:"funding_address"` + FeeAddress string `yaml:"fee_address"` + 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) +} + +func (c clientConfig) dialTimeout() time.Duration { + if c.DialTimeoutSecs <= 0 { + return 5 * time.Second + } + return time.Duration(c.DialTimeoutSecs) * time.Second +} + +func (c clientConfig) callTimeout() time.Duration { + if c.CallTimeoutSecs <= 0 { + return 3 * time.Second + } + return time.Duration(c.CallTimeoutSecs) * time.Second +} + +func (i *Imp) loadConfig() (*config, error) { + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err + } + + cfg := &config{Config: &grpcapp.Config{}} + if err := yaml.Unmarshal(data, cfg); err != nil { + i.logger.Error("Failed to parse configuration", zap.Error(err)) + return nil, err + } + + if cfg.Runtime == nil { + cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} + } + + if cfg.GRPC == nil { + cfg.GRPC = &routers.GRPCConfig{ + Network: "tcp", + Address: ":50062", + EnableReflection: true, + EnableHealth: true, + } + } else { + if strings.TrimSpace(cfg.GRPC.Address) == "" { + cfg.GRPC.Address = ":50062" + } + if strings.TrimSpace(cfg.GRPC.Network) == "" { + cfg.GRPC.Network = "tcp" + } + } + + if cfg.Metrics == nil { + cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9403"} + } else if strings.TrimSpace(cfg.Metrics.Address) == "" { + cfg.Metrics.Address = ":9403" + } + + return cfg, nil +} diff --git a/api/payments/orchestrator/internal/server/internal/dependencies.go b/api/payments/orchestrator/internal/server/internal/dependencies.go new file mode 100644 index 0000000..ae82f9d --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/dependencies.go @@ -0,0 +1,84 @@ +package serverimp + +import ( + oracleclient "github.com/tech/sendico/fx/oracle/client" + 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/service/orchestrator" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" +) + +type orchestratorDeps struct { + feesClient feesv1.FeeEngineClient + ledgerClient ledgerclient.Client + gatewayClient chainclient.Client + mntxClient mntxclient.Client + oracleClient oracleclient.Client +} + +func (i *Imp) initDependencies(cfg *config) *orchestratorDeps { + deps := &orchestratorDeps{} + if cfg == nil { + return deps + } + + deps.feesClient, i.feesConn = i.initFeesClient(cfg.Fees) + + deps.ledgerClient = i.initLedgerClient(cfg.Ledger) + if deps.ledgerClient != nil { + i.ledgerClient = deps.ledgerClient + } + + deps.gatewayClient = i.initGatewayClient(cfg.Gateway) + if deps.gatewayClient != nil { + i.gatewayClient = deps.gatewayClient + } + + deps.mntxClient = i.initMntxClient(cfg.Mntx) + if deps.mntxClient != nil { + i.mntxClient = deps.mntxClient + } + + deps.oracleClient = i.initOracleClient(cfg.Oracle) + if deps.oracleClient != nil { + i.oracleClient = deps.oracleClient + } + + return deps +} + +func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchestrator.Option { + if cfg == nil || deps == nil { + return nil + } + opts := []orchestrator.Option{} + if deps.feesClient != nil { + opts = append(opts, orchestrator.WithFeeEngine(deps.feesClient, cfg.Fees.callTimeout())) + } + if deps.ledgerClient != nil { + opts = append(opts, orchestrator.WithLedgerClient(deps.ledgerClient)) + } + if deps.gatewayClient != nil { + opts = append(opts, orchestrator.WithChainGatewayClient(deps.gatewayClient)) + } + if railGateways := buildRailGateways(deps.gatewayClient, cfg.GatewayInstances); len(railGateways) > 0 { + opts = append(opts, orchestrator.WithRailGateways(railGateways)) + } + if deps.mntxClient != nil { + opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient)) + } + if deps.oracleClient != nil { + opts = append(opts, orchestrator.WithOracleClient(deps.oracleClient)) + } + if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 { + opts = append(opts, orchestrator.WithCardGatewayRoutes(routes)) + } + if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 { + opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts)) + } + if registry := buildGatewayRegistry(i.logger, deps.mntxClient, cfg.GatewayInstances, i.discoveryReg); registry != nil { + opts = append(opts, orchestrator.WithGatewayRegistry(registry)) + } + return opts +} diff --git a/api/payments/orchestrator/internal/server/internal/discovery.go b/api/payments/orchestrator/internal/server/internal/discovery.go new file mode 100644 index 0000000..acd506a --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/discovery.go @@ -0,0 +1,50 @@ +package serverimp + +import ( + "github.com/tech/sendico/payments/orchestrator/internal/appversion" + "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/mservice" + "go.uber.org/zap" +) + +func (i *Imp) initDiscovery(cfg *config) { + if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" { + return + } + logger := i.logger.Named("discovery") + broker, err := msg.CreateMessagingBroker(logger.Named("bus"), cfg.Messaging) + if err != nil { + i.logger.Warn("Failed to initialise discovery broker", zap.Error(err)) + return + } + producer := msgproducer.NewProducer(logger.Named("producer"), broker) + registry := discovery.NewRegistry() + watcher, err := discovery.NewRegistryWatcher(i.logger, broker, registry) + if err != nil { + i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(err)) + } else if err := watcher.Start(); err != nil { + i.logger.Warn("Failed to start discovery registry watcher", zap.Error(err)) + } else { + i.discoveryWatcher = watcher + i.discoveryReg = registry + i.logger.Info("Discovery registry watcher 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() +} + +func (i *Imp) stopDiscovery() { + if i.discoveryAnnouncer != nil { + i.discoveryAnnouncer.Stop() + } + if i.discoveryWatcher != nil { + i.discoveryWatcher.Stop() + } +} diff --git a/api/payments/orchestrator/internal/server/internal/lifecycle.go b/api/payments/orchestrator/internal/server/internal/lifecycle.go new file mode 100644 index 0000000..583700b --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/lifecycle.go @@ -0,0 +1,18 @@ +package serverimp + +import ( + "context" + "time" +) + +func (i *Imp) shutdownApp() { + if i.app != nil { + timeout := 15 * time.Second + if i.config != nil && i.config.Runtime != nil { + timeout = i.config.Runtime.ShutdownTimeout() + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + i.app.Shutdown(ctx) + cancel() + } +} diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 888bc1a..81f3f3b 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -1,136 +1,15 @@ package serverimp import ( - "context" - "crypto/tls" - "os" - "strings" - "time" - - oracleclient "github.com/tech/sendico/fx/oracle/client" - 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" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "gopkg.in/yaml.v3" ) -type Imp struct { - logger mlogger.Logger - file string - debug bool - - 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"` - GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"` -} - -type clientConfig struct { - Address string `yaml:"address"` - DialTimeoutSecs int `yaml:"dial_timeout_seconds"` - CallTimeoutSecs int `yaml:"call_timeout_seconds"` - InsecureTransport bool `yaml:"insecure"` -} - -type cardGatewayRouteConfig struct { - FundingAddress string `yaml:"funding_address"` - FeeAddress string `yaml:"fee_address"` - 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) -} - -func (c clientConfig) dialTimeout() time.Duration { - if c.DialTimeoutSecs <= 0 { - return 5 * time.Second - } - return time.Duration(c.DialTimeoutSecs) * time.Second -} - -func (c clientConfig) callTimeout() time.Duration { - if c.CallTimeoutSecs <= 0 { - return 3 * time.Second - } - return time.Duration(c.CallTimeoutSecs) * time.Second -} - func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { return &Imp{ logger: logger.Named("server"), @@ -140,37 +19,9 @@ 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 { - timeout = i.config.Runtime.ShutdownTimeout() - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - i.app.Shutdown(ctx) - cancel() - } - - if i.ledgerClient != nil { - _ = i.ledgerClient.Close() - } - if i.gatewayClient != nil { - _ = i.gatewayClient.Close() - } - if i.mntxClient != nil { - _ = i.mntxClient.Close() - } - if i.oracleClient != nil { - _ = i.oracleClient.Close() - } - if i.feesConn != nil { - _ = i.feesConn.Close() - } + i.stopDiscovery() + i.shutdownApp() + i.closeClients() } func (i *Imp) Start() error { @@ -180,90 +31,16 @@ 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() - } - } + i.initDiscovery(cfg) repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { return mongostorage.New(logger, conn) } - feesClient, feesConn := i.initFeesClient(cfg.Fees) - if feesConn != nil { - i.feesConn = feesConn - } - - ledgerClient := i.initLedgerClient(cfg.Ledger) - if ledgerClient != nil { - i.ledgerClient = ledgerClient - } - - gatewayClient := i.initGatewayClient(cfg.Gateway) - if gatewayClient != nil { - i.gatewayClient = gatewayClient - } - - mntxClient := i.initMntxClient(cfg.Mntx) - if mntxClient != nil { - i.mntxClient = mntxClient - } - - oracleClient := i.initOracleClient(cfg.Oracle) - if oracleClient != nil { - i.oracleClient = oracleClient - } + deps := i.initDependencies(cfg) serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { - opts := []orchestrator.Option{} - if feesClient != nil { - opts = append(opts, orchestrator.WithFeeEngine(feesClient, cfg.Fees.callTimeout())) - } - if ledgerClient != nil { - opts = append(opts, orchestrator.WithLedgerClient(ledgerClient)) - } - 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)) - } - if oracleClient != nil { - opts = append(opts, orchestrator.WithOracleClient(oracleClient)) - } - if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 { - opts = append(opts, orchestrator.WithCardGatewayRoutes(routes)) - } - 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)) - } + opts := i.buildServiceOptions(cfg, deps) return orchestrator.NewService(logger, repo, opts...), nil } @@ -275,371 +52,3 @@ func (i *Imp) Start() error { return i.app.Start() } - -func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.ClientConn) { - addr := cfg.address() - if addr == "" { - return nil, nil - } - - dialCtx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) - defer cancel() - - creds := credentials.NewTLS(&tls.Config{}) - if cfg.InsecureTransport { - creds = insecure.NewCredentials() - } - - conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds)) - if err != nil { - i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err)) - return nil, nil - } - - i.logger.Info("Connected to fees service", zap.String("address", addr)) - return feesv1.NewFeeEngineClient(conn), conn -} - -func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client { - addr := cfg.address() - if addr == "" { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) - defer cancel() - - client, err := ledgerclient.New(ctx, ledgerclient.Config{ - Address: addr, - DialTimeout: cfg.dialTimeout(), - CallTimeout: cfg.callTimeout(), - Insecure: cfg.InsecureTransport, - }) - if err != nil { - i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err)) - return nil - } - i.logger.Info("Connected to ledger service", zap.String("address", addr)) - return client -} - -func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client { - addr := cfg.address() - if addr == "" { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) - defer cancel() - - client, err := chainclient.New(ctx, chainclient.Config{ - Address: addr, - DialTimeout: cfg.dialTimeout(), - CallTimeout: cfg.callTimeout(), - Insecure: cfg.InsecureTransport, - }) - if err != nil { - i.logger.Warn("failed to connect to chain gateway service", zap.String("address", addr), zap.Error(err)) - return nil - } - i.logger.Info("connected to chain gateway service", zap.String("address", addr)) - return client -} - -func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client { - addr := cfg.address() - if addr == "" { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) - defer cancel() - - client, err := mntxclient.New(ctx, mntxclient.Config{ - Address: addr, - DialTimeout: cfg.dialTimeout(), - CallTimeout: cfg.callTimeout(), - Logger: i.logger.Named("client.mntx"), - }) - if err != nil { - i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err)) - return nil - } - i.logger.Info("Connected to mntx gateway service", zap.String("address", addr)) - return client -} - -func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client { - addr := cfg.address() - if addr == "" { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) - defer cancel() - - client, err := oracleclient.New(ctx, oracleclient.Config{ - Address: addr, - DialTimeout: cfg.dialTimeout(), - CallTimeout: cfg.callTimeout(), - Insecure: cfg.InsecureTransport, - }) - if err != nil { - i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err)) - return nil - } - i.logger.Info("Connected to oracle service", zap.String("address", addr)) - return client -} - -func (i *Imp) loadConfig() (*config, error) { - data, err := os.ReadFile(i.file) - if err != nil { - i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) - return nil, err - } - - cfg := &config{Config: &grpcapp.Config{}} - if err := yaml.Unmarshal(data, cfg); err != nil { - i.logger.Error("Failed to parse configuration", zap.Error(err)) - return nil, err - } - - if cfg.Runtime == nil { - cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} - } - - if cfg.GRPC == nil { - cfg.GRPC = &routers.GRPCConfig{ - Network: "tcp", - Address: ":50062", - EnableReflection: true, - EnableHealth: true, - } - } else { - if strings.TrimSpace(cfg.GRPC.Address) == "" { - cfg.GRPC.Address = ":50062" - } - if strings.TrimSpace(cfg.GRPC.Network) == "" { - cfg.GRPC.Network = "tcp" - } - } - - if cfg.Metrics == nil { - cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9403"} - } else if strings.TrimSpace(cfg.Metrics.Address) == "" { - cfg.Metrics.Address = ":9403" - } - - return cfg, nil -} - -func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute { - if len(src) == 0 { - return nil - } - result := make(map[string]orchestrator.CardGatewayRoute, len(src)) - for key, route := range src { - trimmedKey := strings.TrimSpace(key) - if trimmedKey == "" { - continue - } - result[trimmedKey] = orchestrator.CardGatewayRoute{ - FundingAddress: strings.TrimSpace(route.FundingAddress), - FeeAddress: strings.TrimSpace(route.FeeAddress), - FeeWalletRef: strings.TrimSpace(route.FeeWalletRef), - } - } - return result -} - -func buildFeeLedgerAccounts(src map[string]string) map[string]string { - if len(src) == 0 { - return nil - } - result := make(map[string]string, len(src)) - for key, account := range src { - k := strings.ToLower(strings.TrimSpace(key)) - v := strings.TrimSpace(account) - if k == "" || v == "" { - continue - } - result[k] = v - } - 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/server/internal/types.go b/api/payments/orchestrator/internal/server/internal/types.go new file mode 100644 index 0000000..10b3a38 --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/types.go @@ -0,0 +1,30 @@ +package serverimp + +import ( + oracleclient "github.com/tech/sendico/fx/oracle/client" + 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" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server/grpcapp" + "google.golang.org/grpc" +) + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config + app *grpcapp.App[storage.Repository] + discoveryWatcher *discovery.RegistryWatcher + discoveryReg *discovery.Registry + discoveryAnnouncer *discovery.Announcer + feesConn *grpc.ClientConn + ledgerClient ledgerclient.Client + gatewayClient chainclient.Client + mntxClient mntxclient.Client + oracleClient oracleclient.Client +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go index 4ebb90f..606ae52 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go @@ -45,6 +45,7 @@ func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInst } items = append(items, &model.GatewayInstanceDescriptor{ ID: entry.ID, + InstanceID: entry.InstanceID, Rail: rail, Network: entry.Network, Currencies: normalizeCurrencies(entry.Currencies), diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index 4d01956..88c33e7 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -113,6 +113,7 @@ type Limits struct { // GatewayInstanceDescriptor standardizes gateway instance self-declaration. type GatewayInstanceDescriptor struct { ID string `bson:"id" json:"id"` + InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"` Rail Rail `bson:"rail" json:"rail"` Network string `bson:"network,omitempty" json:"network,omitempty"` Currencies []string `bson:"currencies,omitempty" json:"currencies,omitempty"` diff --git a/api/pkg/discovery/announcer.go b/api/pkg/discovery/announcer.go index 5480974..1ec9e31 100644 --- a/api/pkg/discovery/announcer.go +++ b/api/pkg/discovery/announcer.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) type Announcer struct { @@ -31,8 +32,11 @@ func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, a if announce.Service == "" { announce.Service = strings.TrimSpace(sender) } + if announce.InstanceID == "" { + announce.InstanceID = InstanceID() + } if announce.ID == "" { - announce.ID = DefaultInstanceID(announce.Service) + announce.ID = DefaultEntryID(announce.Service) } if announce.InvokeURI == "" && announce.Service != "" { announce.InvokeURI = DefaultInvokeURI(announce.Service) @@ -53,15 +57,16 @@ func (a *Announcer) Start() { } a.startOnce.Do(func() { if a.producer == nil { - a.logWarn("Discovery announce skipped: producer not configured") + a.logWarn("Discovery announce skipped: producer not configured", announcementFields(a.announce)...) close(a.doneCh) return } if strings.TrimSpace(a.announce.ID) == "" { - a.logWarn("Discovery announce skipped: missing instance id") + a.logWarn("Discovery announce skipped: missing instance id", announcementFields(a.announce)...) close(a.doneCh) return } + a.logInfo("Discovery announcer starting", announcementFields(a.announce)...) a.sendAnnouncement() a.sendHeartbeat() go a.heartbeatLoop() @@ -75,6 +80,7 @@ func (a *Announcer) Stop() { a.stopOnce.Do(func() { close(a.stopCh) <-a.doneCh + a.logInfo("Discovery announcer stopped", announcementFields(a.announce)...) }) } @@ -99,42 +105,47 @@ func (a *Announcer) heartbeatLoop() { func (a *Announcer) sendAnnouncement() { env := NewServiceAnnounceEnvelope(a.sender, a.announce) + event := ServiceAnnounceEvent() if a.announce.Rail != "" { env = NewGatewayAnnounceEnvelope(a.sender, a.announce) + event = GatewayAnnounceEvent() } if err := a.producer.SendMessage(env); err != nil { - a.logWarn("Failed to publish discovery announce: " + err.Error()) + fields := append(announcementFields(a.announce), zap.String("event", event.ToString()), zap.Error(err)) + a.logWarn("Failed to publish discovery announce", fields...) return } - a.logInfo("Discovery announce published") + a.logInfo("Discovery announce published", append(announcementFields(a.announce), zap.String("event", event.ToString()))...) } func (a *Announcer) sendHeartbeat() { hb := Heartbeat{ - ID: a.announce.ID, - Status: "ok", - TS: time.Now().Unix(), + ID: a.announce.ID, + InstanceID: a.announce.InstanceID, + 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()) + fields := append(announcementFields(a.announce), zap.String("event", HeartbeatEvent().ToString()), zap.Error(err)) + a.logWarn("Failed to publish discovery heartbeat", fields...) } } -func (a *Announcer) logInfo(message string) { +func (a *Announcer) logInfo(message string, fields ...zap.Field) { if a.logger == nil { return } - a.logger.Info(message) + a.logger.Info(message, fields...) } -func (a *Announcer) logWarn(message string) { +func (a *Announcer) logWarn(message string, fields ...zap.Field) { if a.logger == nil { return } - a.logger.Warn(message) + a.logger.Warn(message, fields...) } -func DefaultInstanceID(service string) string { +func DefaultEntryID(service string) string { clean := strings.ToLower(strings.TrimSpace(service)) if clean == "" { clean = "service" @@ -148,6 +159,10 @@ func DefaultInstanceID(service string) string { return clean + "_" + host + "_" + uid } +func DefaultInstanceID(service string) string { + return DefaultEntryID(service) +} + func DefaultInvokeURI(service string) string { clean := strings.ToLower(strings.TrimSpace(service)) if clean == "" { diff --git a/api/pkg/discovery/client.go b/api/pkg/discovery/client.go index 40ff434..b82f56d 100644 --- a/api/pkg/discovery/client.go +++ b/api/pkg/discovery/client.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" msg "github.com/tech/sendico/pkg/messaging" - "github.com/tech/sendico/pkg/messaging/broker" + mb "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" @@ -27,22 +27,22 @@ type Client struct { pending map[string]chan LookupResponse } -func NewClient(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, sender string) (*Client, error) { - if broker == nil { +func NewClient(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, sender string) (*Client, error) { + if msgBroker == 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) + producer = msgproducer.NewProducer(logger, msgBroker) } sender = strings.TrimSpace(sender) if sender == "" { sender = "discovery_client" } - consumer, err := cons.NewConsumer(logger, broker, LookupResponseEvent()) + consumer, err := cons.NewConsumer(logger, msgBroker, LookupResponseEvent()) if err != nil { return nil, err } @@ -57,7 +57,7 @@ func NewClient(logger mlogger.Logger, broker broker.Broker, producer msg.Produce go func() { if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil && client.logger != nil { - client.logger.Warn("Discovery lookup consumer stopped", zap.Error(err)) + client.logger.Warn("Discovery lookup consumer stopped", zap.String("event", LookupResponseEvent().ToString()), zap.Error(err)) } }() @@ -112,7 +112,8 @@ func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) { 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)) + fields := append(envelopeFields(env), zap.Int("data_len", len(env.GetData())), zap.Error(err)) + c.logWarn("Failed to decode discovery lookup response", fields...) return err } requestID := strings.TrimSpace(payload.RequestID) diff --git a/api/pkg/discovery/instanceid.go b/api/pkg/discovery/instanceid.go new file mode 100644 index 0000000..f219601 --- /dev/null +++ b/api/pkg/discovery/instanceid.go @@ -0,0 +1,27 @@ +package discovery + +import ( + "strings" + "sync" + + "github.com/google/uuid" +) + +var ( + instanceID string + instanceOnce sync.Once + instanceIDGenerator = func() string { + return uuid.NewString() + } +) + +// InstanceID returns a unique, process-stable identifier for the running service instance. +func InstanceID() string { + instanceOnce.Do(func() { + instanceID = strings.TrimSpace(instanceIDGenerator()) + if instanceID == "" { + instanceID = uuid.NewString() + } + }) + return instanceID +} diff --git a/api/pkg/discovery/instanceid_test.go b/api/pkg/discovery/instanceid_test.go new file mode 100644 index 0000000..2948652 --- /dev/null +++ b/api/pkg/discovery/instanceid_test.go @@ -0,0 +1,53 @@ +package discovery + +import ( + "fmt" + "sync" + "testing" +) + +func resetInstanceIDForTest() { + instanceID = "" + instanceOnce = sync.Once{} +} + +func TestInstanceIDStable(t *testing.T) { + resetInstanceIDForTest() + original := instanceIDGenerator + defer func() { + instanceIDGenerator = original + resetInstanceIDForTest() + }() + + instanceIDGenerator = func() string { + return "fixed-id" + } + + first := InstanceID() + second := InstanceID() + if first != "fixed-id" || second != "fixed-id" { + t.Fatalf("expected stable instance id, got %q and %q", first, second) + } +} + +func TestInstanceIDRegeneratesAfterReset(t *testing.T) { + resetInstanceIDForTest() + original := instanceIDGenerator + defer func() { + instanceIDGenerator = original + resetInstanceIDForTest() + }() + + counter := 0 + instanceIDGenerator = func() string { + counter++ + return fmt.Sprintf("id-%d", counter) + } + + first := InstanceID() + resetInstanceIDForTest() + second := InstanceID() + if first == second { + t.Fatalf("expected new instance id after reset, got %q", first) + } +} diff --git a/api/pkg/discovery/keys.go b/api/pkg/discovery/keys.go new file mode 100644 index 0000000..440fd42 --- /dev/null +++ b/api/pkg/discovery/keys.go @@ -0,0 +1,99 @@ +package discovery + +import "strings" + +const kvEntryPrefix = "entry." + +func registryEntryKey(entry RegistryEntry) string { + return registryKey(entry.Service, entry.Rail, entry.Network, entry.Operations, entry.Version, entry.InstanceID) +} + +func registryKey(service, rail, network string, operations []string, version, instanceID string) string { + service = normalizeKeyPart(service) + rail = normalizeKeyPart(rail) + op := normalizeKeyPart(firstOperation(operations)) + version = normalizeKeyPart(version) + instanceID = normalizeKeyPart(instanceID) + if instanceID == "" { + return "" + } + if service == "" { + service = "service" + } + if rail == "" { + rail = "none" + } + if op == "" { + op = "none" + } + if version == "" { + version = "unknown" + } + parts := []string{service, rail, op, version, instanceID} + if network != "" { + netPart := normalizeKeyPart(network) + if netPart != "" { + parts = append(parts, netPart) + } + } + return strings.Join(parts, ".") +} + +func kvKeyFromRegistryKey(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + if strings.HasPrefix(key, kvEntryPrefix) { + return key + } + return kvEntryPrefix + key +} + +func registryKeyFromKVKey(key string) string { + key = strings.TrimSpace(key) + if strings.HasPrefix(key, kvEntryPrefix) { + return strings.TrimPrefix(key, kvEntryPrefix) + } + return key +} + +func firstOperation(ops []string) string { + for _, op := range ops { + op = strings.TrimSpace(op) + if op != "" { + return op + } + } + return "" +} + +func normalizeKeyPart(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + var b strings.Builder + b.Grow(len(value)) + lastDash := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + continue + } + if r == '-' || r == '_' { + if !lastDash { + b.WriteByte('-') + lastDash = true + } + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + return out +} diff --git a/api/pkg/discovery/kv.go b/api/pkg/discovery/kv.go new file mode 100644 index 0000000..21ba7a9 --- /dev/null +++ b/api/pkg/discovery/kv.go @@ -0,0 +1,103 @@ +package discovery + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/nats-io/nats.go" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const DefaultKVBucket = "discovery_registry" + +type KVStore struct { + logger mlogger.Logger + kv nats.KeyValue + bucket string +} + +func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string) (*KVStore, error) { + if js == nil { + return nil, errors.New("discovery kv: jetstream is nil") + } + if logger != nil { + logger = logger.Named("discovery_kv") + } + bucket = strings.TrimSpace(bucket) + if bucket == "" { + bucket = DefaultKVBucket + } + kv, err := js.KeyValue(bucket) + if err != nil { + if errors.Is(err, nats.ErrBucketNotFound) { + kv, err = js.CreateKeyValue(&nats.KeyValueConfig{ + Bucket: bucket, + Description: "service discovery registry", + History: 1, + }) + if err == nil && logger != nil { + logger.Info("Discovery KV bucket created", zap.String("bucket", bucket)) + } + } + if err != nil { + return nil, err + } + } + + return &KVStore{ + logger: logger, + kv: kv, + bucket: bucket, + }, nil +} + +func (s *KVStore) Put(entry RegistryEntry) error { + if s == nil || s.kv == nil { + return errors.New("discovery kv: not configured") + } + key := registryEntryKey(normalizeEntry(entry)) + if key == "" { + return errors.New("discovery kv: entry key is empty") + } + payload, err := json.Marshal(entry) + if err != nil { + return err + } + _, err = s.kv.Put(kvKeyFromRegistryKey(key), payload) + if err != nil && s.logger != nil { + fields := append(entryFields(entry), zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err)) + s.logger.Warn("Failed to persist discovery entry", fields...) + } + return err +} + +func (s *KVStore) Delete(id string) error { + if s == nil || s.kv == nil { + return errors.New("discovery kv: not configured") + } + key := kvKeyFromRegistryKey(id) + if key == "" { + return nil + } + if err := s.kv.Delete(key); err != nil && s.logger != nil { + s.logger.Warn("Failed to delete discovery entry", zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err)) + return err + } + return nil +} + +func (s *KVStore) WatchAll() (nats.KeyWatcher, error) { + if s == nil || s.kv == nil { + return nil, errors.New("discovery kv: not configured") + } + return s.kv.WatchAll() +} + +func (s *KVStore) Bucket() string { + if s == nil { + return "" + } + return s.bucket +} diff --git a/api/pkg/discovery/logging.go b/api/pkg/discovery/logging.go new file mode 100644 index 0000000..4a1a94b --- /dev/null +++ b/api/pkg/discovery/logging.go @@ -0,0 +1,108 @@ +package discovery + +import ( + "strings" + + me "github.com/tech/sendico/pkg/messaging/envelope" + "go.uber.org/zap" +) + +func announcementFields(announce Announcement) []zap.Field { + fields := make([]zap.Field, 0, 10) + if announce.ID != "" { + fields = append(fields, zap.String("id", announce.ID)) + } + if announce.InstanceID != "" { + fields = append(fields, zap.String("instance_id", announce.InstanceID)) + } + if announce.Service != "" { + fields = append(fields, zap.String("service", announce.Service)) + } + if announce.Rail != "" { + fields = append(fields, zap.String("rail", announce.Rail)) + } + if announce.Network != "" { + fields = append(fields, zap.String("network", announce.Network)) + } + if announce.InvokeURI != "" { + fields = append(fields, zap.String("invoke_uri", announce.InvokeURI)) + } + if announce.Version != "" { + fields = append(fields, zap.String("version", announce.Version)) + } + if announce.RoutingPriority != 0 { + fields = append(fields, zap.Int("routing_priority", announce.RoutingPriority)) + } + if len(announce.Operations) > 0 { + fields = append(fields, zap.Int("ops", len(announce.Operations))) + } + if len(announce.Currencies) > 0 { + fields = append(fields, zap.Int("currencies", len(announce.Currencies))) + } + if announce.Health.IntervalSec > 0 { + fields = append(fields, zap.Int("interval_sec", announce.Health.IntervalSec)) + } + if announce.Health.TimeoutSec > 0 { + fields = append(fields, zap.Int("timeout_sec", announce.Health.TimeoutSec)) + } + return fields +} + +func entryFields(entry RegistryEntry) []zap.Field { + fields := make([]zap.Field, 0, 12) + if entry.ID != "" { + fields = append(fields, zap.String("id", entry.ID)) + } + if entry.InstanceID != "" { + fields = append(fields, zap.String("instance_id", entry.InstanceID)) + } + if entry.Service != "" { + fields = append(fields, zap.String("service", entry.Service)) + } + if entry.Rail != "" { + fields = append(fields, zap.String("rail", entry.Rail)) + } + if entry.Network != "" { + fields = append(fields, zap.String("network", entry.Network)) + } + if entry.Version != "" { + fields = append(fields, zap.String("version", entry.Version)) + } + if entry.InvokeURI != "" { + fields = append(fields, zap.String("invoke_uri", entry.InvokeURI)) + } + if entry.Status != "" { + fields = append(fields, zap.String("status", entry.Status)) + } + if !entry.LastHeartbeat.IsZero() { + fields = append(fields, zap.Time("last_heartbeat", entry.LastHeartbeat)) + } + fields = append(fields, zap.Bool("healthy", entry.Healthy)) + if entry.RoutingPriority != 0 { + fields = append(fields, zap.Int("routing_priority", entry.RoutingPriority)) + } + if len(entry.Operations) > 0 { + fields = append(fields, zap.Int("ops", len(entry.Operations))) + } + if len(entry.Currencies) > 0 { + fields = append(fields, zap.Int("currencies", len(entry.Currencies))) + } + return fields +} + +func envelopeFields(env me.Envelope) []zap.Field { + if env == nil { + return nil + } + fields := make([]zap.Field, 0, 4) + sender := strings.TrimSpace(env.GetSender()) + if sender != "" { + fields = append(fields, zap.String("sender", sender)) + } + if signature := env.GetSignature(); signature != nil { + fields = append(fields, zap.String("event", signature.ToString())) + } + fields = append(fields, zap.String("message_id", env.GetMessageId().String())) + fields = append(fields, zap.Time("timestamp", env.GetTimeStamp())) + return fields +} diff --git a/api/pkg/discovery/lookup.go b/api/pkg/discovery/lookup.go index 2658307..879e70f 100644 --- a/api/pkg/discovery/lookup.go +++ b/api/pkg/discovery/lookup.go @@ -1,5 +1,7 @@ package discovery +import "time" + type LookupRequest struct { RequestID string `json:"requestId,omitempty"` } @@ -11,16 +13,18 @@ type LookupResponse struct { } 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"` + ID string `json:"id"` + InstanceID string `json:"instanceId"` + 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"` + InstanceID string `json:"instanceId"` Rail string `json:"rail"` Network string `json:"network,omitempty"` Currencies []string `json:"currencies,omitempty"` @@ -43,6 +47,7 @@ func (r *Registry) Lookup(now time.Time) LookupResponse { if entry.Rail != "" { resp.Gateways = append(resp.Gateways, GatewaySummary{ ID: entry.ID, + InstanceID: entry.InstanceID, Rail: entry.Rail, Network: entry.Network, Currencies: cloneStrings(entry.Currencies), @@ -56,12 +61,13 @@ func (r *Registry) Lookup(now time.Time) LookupResponse { 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, + ID: entry.ID, + InstanceID: entry.InstanceID, + Service: entry.Service, + Ops: cloneStrings(entry.Operations), + Version: entry.Version, + Healthy: entry.Healthy, + InvokeURI: entry.InvokeURI, }) } diff --git a/api/pkg/discovery/registry.go b/api/pkg/discovery/registry.go index 8644d35..4f6c0cd 100644 --- a/api/pkg/discovery/registry.go +++ b/api/pkg/discovery/registry.go @@ -13,6 +13,7 @@ const ( type RegistryEntry struct { ID string `json:"id"` + InstanceID string `bson:"instanceId" json:"instanceId"` Service string `json:"service"` Rail string `json:"rail,omitempty"` Network string `json:"network,omitempty"` @@ -29,8 +30,10 @@ type RegistryEntry struct { } type Registry struct { - mu sync.RWMutex - entries map[string]*RegistryEntry + mu sync.RWMutex + entries map[string]*RegistryEntry + byID map[string]map[string]struct{} + byInstance map[string]map[string]struct{} } type UpdateResult struct { @@ -42,23 +45,31 @@ type UpdateResult struct { func NewRegistry() *Registry { return &Registry{ - entries: map[string]*RegistryEntry{}, + entries: map[string]*RegistryEntry{}, + byID: map[string]map[string]struct{}{}, + byInstance: map[string]map[string]struct{}{}, } } func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time) UpdateResult { entry := registryEntryFromAnnouncement(normalizeAnnouncement(announce), now) + key := registryEntryKey(entry) + if key == "" { + return UpdateResult{Entry: entry} + } r.mu.Lock() defer r.mu.Unlock() - existing, ok := r.entries[entry.ID] + existing, ok := r.entries[key] wasHealthy := false if ok && existing != nil { wasHealthy = existing.isHealthyAt(now) + r.unindexEntry(key, existing) } entry.Healthy = entry.isHealthyAt(now) - r.entries[entry.ID] = &entry + r.entries[key] = &entry + r.indexEntry(key, &entry) return UpdateResult{ Entry: entry, @@ -68,10 +79,45 @@ func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time) } } -func (r *Registry) UpdateHeartbeat(id string, status string, ts time.Time, now time.Time) (UpdateResult, bool) { +func (r *Registry) UpsertEntry(entry RegistryEntry, now time.Time) UpdateResult { + entry = normalizeEntry(entry) + key := registryEntryKey(entry) + if key == "" { + return UpdateResult{Entry: entry} + } + if entry.LastHeartbeat.IsZero() { + entry.LastHeartbeat = now + } + if strings.TrimSpace(entry.Status) == "" { + entry.Status = "ok" + } + + r.mu.Lock() + defer r.mu.Unlock() + + existing, ok := r.entries[key] + wasHealthy := false + if ok && existing != nil { + wasHealthy = existing.isHealthyAt(now) + r.unindexEntry(key, existing) + } + entry.Healthy = entry.isHealthyAt(now) + r.entries[key] = &entry + r.indexEntry(key, &entry) + + return UpdateResult{ + Entry: entry, + IsNew: !ok, + WasHealthy: wasHealthy, + BecameHealthy: !wasHealthy && entry.Healthy, + } +} + +func (r *Registry) UpdateHeartbeat(id string, instanceID string, status string, ts time.Time, now time.Time) []UpdateResult { id = strings.TrimSpace(id) - if id == "" { - return UpdateResult{}, false + instanceID = strings.TrimSpace(instanceID) + if id == "" && instanceID == "" { + return nil } if status == "" { status = "ok" @@ -83,21 +129,54 @@ func (r *Registry) UpdateHeartbeat(id string, status string, ts time.Time, now t r.mu.Lock() defer r.mu.Unlock() - entry, ok := r.entries[id] - if !ok || entry == nil { - return UpdateResult{}, false + keys := keysFromIndex(r.byInstance[instanceID]) + if len(keys) == 0 && id != "" { + keys = keysFromIndex(r.byID[id]) } - wasHealthy := entry.isHealthyAt(now) - entry.Status = status - entry.LastHeartbeat = ts - entry.Healthy = entry.isHealthyAt(now) + if len(keys) == 0 { + return nil + } + results := make([]UpdateResult, 0, len(keys)) + for _, key := range keys { + entry := r.entries[key] + if entry == nil { + continue + } + if id != "" && entry.ID != id { + continue + } + if instanceID != "" && entry.InstanceID != instanceID { + continue + } + 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 + results = append(results, UpdateResult{ + Entry: *entry, + IsNew: false, + WasHealthy: wasHealthy, + BecameHealthy: !wasHealthy && entry.Healthy, + }) + } + return results +} + +func (r *Registry) Delete(key string) bool { + key = strings.TrimSpace(key) + if key == "" { + return false + } + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.entries[key] + if !ok { + return false + } + delete(r.entries, key) + r.unindexEntry(key, entry) + return true } func (r *Registry) List(now time.Time, onlyHealthy bool) []RegistryEntry { @@ -123,6 +202,7 @@ func registryEntryFromAnnouncement(announce Announcement, now time.Time) Registr status := "ok" return RegistryEntry{ ID: strings.TrimSpace(announce.ID), + InstanceID: strings.TrimSpace(announce.InstanceID), Service: strings.TrimSpace(announce.Service), Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)), Network: strings.ToUpper(strings.TrimSpace(announce.Network)), @@ -138,8 +218,33 @@ func registryEntryFromAnnouncement(announce Announcement, now time.Time) Registr } } +func normalizeEntry(entry RegistryEntry) RegistryEntry { + entry.ID = strings.TrimSpace(entry.ID) + entry.InstanceID = strings.TrimSpace(entry.InstanceID) + if entry.InstanceID == "" { + entry.InstanceID = entry.ID + } + entry.Service = strings.TrimSpace(entry.Service) + entry.Rail = strings.ToUpper(strings.TrimSpace(entry.Rail)) + entry.Network = strings.ToUpper(strings.TrimSpace(entry.Network)) + entry.Operations = normalizeStrings(entry.Operations, false) + entry.Currencies = normalizeStrings(entry.Currencies, true) + entry.InvokeURI = strings.TrimSpace(entry.InvokeURI) + entry.Version = strings.TrimSpace(entry.Version) + entry.Status = strings.TrimSpace(entry.Status) + entry.Health = normalizeHealth(entry.Health) + if entry.Limits != nil { + entry.Limits = normalizeLimits(*entry.Limits) + } + return entry +} + func normalizeAnnouncement(announce Announcement) Announcement { announce.ID = strings.TrimSpace(announce.ID) + announce.InstanceID = strings.TrimSpace(announce.InstanceID) + if announce.InstanceID == "" { + announce.InstanceID = announce.ID + } announce.Service = strings.TrimSpace(announce.Service) announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail)) announce.Network = strings.ToUpper(strings.TrimSpace(announce.Network)) @@ -239,6 +344,67 @@ func cloneStrings(values []string) []string { return out } +func (r *Registry) indexEntry(key string, entry *RegistryEntry) { + if r == nil || entry == nil || key == "" { + return + } + if entry.ID != "" { + addIndex(r.byID, entry.ID, key) + } + if entry.InstanceID != "" { + addIndex(r.byInstance, entry.InstanceID, key) + } +} + +func (r *Registry) unindexEntry(key string, entry *RegistryEntry) { + if r == nil || entry == nil || key == "" { + return + } + if entry.ID != "" { + removeIndex(r.byID, entry.ID, key) + } + if entry.InstanceID != "" { + removeIndex(r.byInstance, entry.InstanceID, key) + } +} + +func addIndex(index map[string]map[string]struct{}, id string, key string) { + if id == "" || key == "" { + return + } + set := index[id] + if set == nil { + set = map[string]struct{}{} + index[id] = set + } + set[key] = struct{}{} +} + +func removeIndex(index map[string]map[string]struct{}, id string, key string) { + if id == "" || key == "" { + return + } + set := index[id] + if set == nil { + return + } + delete(set, key) + if len(set) == 0 { + delete(index, id) + } +} + +func keysFromIndex(index map[string]struct{}) []string { + if len(index) == 0 { + return nil + } + keys := make([]string, 0, len(index)) + for key := range index { + keys = append(keys, key) + } + return keys +} + func (e *RegistryEntry) isHealthyAt(now time.Time) bool { if e == nil { return false diff --git a/api/pkg/discovery/service.go b/api/pkg/discovery/service.go index 491c203..ea28190 100644 --- a/api/pkg/discovery/service.go +++ b/api/pkg/discovery/service.go @@ -8,8 +8,9 @@ import ( "sync" "time" + "github.com/nats-io/nats.go" msg "github.com/tech/sendico/pkg/messaging" - "github.com/tech/sendico/pkg/messaging/broker" + mb "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" @@ -22,6 +23,8 @@ type RegistryService struct { producer msg.Producer sender string consumers []consumerHandler + kv *KVStore + kvWatcher nats.KeyWatcher startOnce sync.Once stopOnce sync.Once @@ -30,10 +33,11 @@ type RegistryService struct { type consumerHandler struct { consumer msg.Consumer handler msg.MessageHandlerT + event string } -func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) { - if broker == nil { +func NewRegistryService(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) { + if msgBroker == nil { return nil, errors.New("discovery registry: broker is nil") } if registry == nil { @@ -47,19 +51,19 @@ func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer ms sender = "discovery" } - serviceConsumer, err := cons.NewConsumer(logger, broker, ServiceAnnounceEvent()) + serviceConsumer, err := cons.NewConsumer(logger, msgBroker, ServiceAnnounceEvent()) if err != nil { return nil, err } - gatewayConsumer, err := cons.NewConsumer(logger, broker, GatewayAnnounceEvent()) + gatewayConsumer, err := cons.NewConsumer(logger, msgBroker, GatewayAnnounceEvent()) if err != nil { return nil, err } - heartbeatConsumer, err := cons.NewConsumer(logger, broker, HeartbeatEvent()) + heartbeatConsumer, err := cons.NewConsumer(logger, msgBroker, HeartbeatEvent()) if err != nil { return nil, err } - lookupConsumer, err := cons.NewConsumer(logger, broker, LookupRequestEvent()) + lookupConsumer, err := cons.NewConsumer(logger, msgBroker, LookupRequestEvent()) if err != nil { return nil, err } @@ -69,17 +73,18 @@ func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer ms 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}, - }, } + svc.consumers = []consumerHandler{ + {consumer: serviceConsumer, event: ServiceAnnounceEvent().ToString(), handler: func(ctx context.Context, env me.Envelope) error { + return svc.handleAnnounce(ctx, env) + }}, + {consumer: gatewayConsumer, event: GatewayAnnounceEvent().ToString(), handler: func(ctx context.Context, env me.Envelope) error { + return svc.handleAnnounce(ctx, env) + }}, + {consumer: heartbeatConsumer, event: HeartbeatEvent().ToString(), handler: svc.handleHeartbeat}, + {consumer: lookupConsumer, event: LookupRequestEvent().ToString(), handler: svc.handleLookup}, + } + svc.initKV(msgBroker) return svc, nil } @@ -88,14 +93,16 @@ func (s *RegistryService) Start() { return } s.startOnce.Do(func() { + s.logInfo("Discovery registry service starting", zap.Int("consumers", len(s.consumers)), zap.Bool("kv_enabled", s.kv != nil)) 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)) + s.logger.Warn("Discovery consumer stopped with error", zap.String("event", ch.event), zap.Error(err)) } }() } + s.startKVWatch() }) } @@ -109,18 +116,29 @@ func (s *RegistryService) Stop() { ch.consumer.Close() } } + if s.kvWatcher != nil { + _ = s.kvWatcher.Stop() + } + s.logInfo("Discovery registry service stopped") }) } 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)) + fields := append(envelopeFields(env), zap.Int("data_len", len(env.GetData())), zap.Error(err)) + s.logWarn("Failed to decode discovery announce payload", fields...) return err } + if strings.TrimSpace(payload.InstanceID) == "" { + fields := append(envelopeFields(env), announcementFields(payload)...) + s.logWarn("Discovery announce missing instance id", fields...) + } now := time.Now() result := s.registry.UpsertFromAnnouncement(payload, now) + s.persistEntry(result.Entry) if result.IsNew || result.BecameHealthy { + s.logInfo("Discovery registry entry updated", append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy))...) s.publishRefresh(result.Entry) } return nil @@ -129,37 +147,48 @@ func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) err 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)) + fields := append(envelopeFields(env), zap.Int("data_len", len(env.GetData())), zap.Error(err)) + s.logWarn("Failed to decode discovery heartbeat payload", fields...) return err } - if payload.ID == "" { + if strings.TrimSpace(payload.InstanceID) == "" && strings.TrimSpace(payload.ID) == "" { return nil } + if strings.TrimSpace(payload.InstanceID) == "" { + fields := append(envelopeFields(env), zap.String("id", payload.ID)) + s.logWarn("Discovery heartbeat missing instance id", fields...) + } 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) + results := s.registry.UpdateHeartbeat(payload.ID, payload.InstanceID, strings.TrimSpace(payload.Status), ts, time.Now()) + for _, result := range results { + if result.BecameHealthy { + s.logInfo("Discovery registry entry became healthy", append(entryFields(result.Entry), zap.String("status", result.Entry.Status))...) + s.publishRefresh(result.Entry) + } + s.persistEntry(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") + s.logWarn("Discovery lookup request ignored: producer not configured", envelopeFields(env)...) 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)) + fields := append(envelopeFields(env), zap.Int("data_len", len(env.GetData())), zap.Error(err)) + s.logWarn("Failed to decode discovery lookup payload", fields...) 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)) + fields := []zap.Field{zap.String("request_id", resp.RequestID), zap.Error(err)} + s.logWarn("Failed to publish discovery lookup response", fields...) return err } return nil @@ -170,13 +199,99 @@ func (s *RegistryService) publishRefresh(entry RegistryEntry) { return } payload := RefreshEvent{ - Service: entry.Service, - Rail: entry.Rail, - Network: entry.Network, - Message: "new module available", + InstanceID: entry.InstanceID, + 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)) + fields := append(entryFields(entry), zap.Error(err)) + s.logWarn("Failed to publish discovery refresh event", fields...) + } +} + +type jetStreamProvider interface { + JetStream() nats.JetStreamContext +} + +func (s *RegistryService) initKV(msgBroker mb.Broker) { + if s == nil || msgBroker == nil { + return + } + provider, ok := msgBroker.(jetStreamProvider) + if !ok { + return + } + js := provider.JetStream() + if js == nil { + return + } + store, err := NewKVStore(s.logger, js, "") + if err != nil { + s.logWarn("Failed to initialise discovery KV store", zap.Error(err)) + return + } + s.kv = store +} + +func (s *RegistryService) startKVWatch() { + if s == nil || s.kv == nil { + return + } + watcher, err := s.kv.WatchAll() + if err != nil { + s.logWarn("Failed to start discovery KV watch", zap.Error(err)) + return + } + s.kvWatcher = watcher + if bucket := s.kv.Bucket(); bucket != "" { + s.logInfo("Discovery KV watch started", zap.String("bucket", bucket)) + } + go s.consumeKVUpdates(watcher) +} + +func (s *RegistryService) consumeKVUpdates(watcher nats.KeyWatcher) { + if s == nil || watcher == nil { + return + } + for entry := range watcher.Updates() { + if entry == nil { + continue + } + switch entry.Operation() { + case nats.KeyValueDelete, nats.KeyValuePurge: + key := registryKeyFromKVKey(entry.Key()) + if key != "" { + if s.registry.Delete(key) { + s.logInfo("Discovery registry entry removed", zap.String("key", key)) + } + } + continue + case nats.KeyValuePut: + default: + continue + } + + var payload RegistryEntry + if err := json.Unmarshal(entry.Value(), &payload); err != nil { + s.logWarn("Failed to decode discovery KV entry", zap.String("key", entry.Key()), zap.Error(err)) + continue + } + result := s.registry.UpsertEntry(payload, time.Now()) + if result.IsNew || result.BecameHealthy { + s.logInfo("Discovery registry entry updated from KV", append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy))...) + s.publishRefresh(result.Entry) + } + } +} + +func (s *RegistryService) persistEntry(entry RegistryEntry) { + if s == nil || s.kv == nil { + return + } + if err := s.kv.Put(entry); err != nil { + s.logWarn("Failed to persist discovery entry", append(entryFields(entry), zap.Error(err))...) } } @@ -186,3 +301,10 @@ func (s *RegistryService) logWarn(message string, fields ...zap.Field) { } s.logger.Warn(message, fields...) } + +func (s *RegistryService) logInfo(message string, fields ...zap.Field) { + if s.logger == nil { + return + } + s.logger.Info(message, fields...) +} diff --git a/api/pkg/discovery/types.go b/api/pkg/discovery/types.go index 8a0e19a..cb2fde7 100644 --- a/api/pkg/discovery/types.go +++ b/api/pkg/discovery/types.go @@ -14,6 +14,7 @@ type Limits struct { type Announcement struct { ID string `json:"id"` + InstanceID string `bson:"instanceId" json:"instanceId"` Service string `json:"service"` Rail string `json:"rail,omitempty"` Network string `json:"network,omitempty"` @@ -27,14 +28,16 @@ type Announcement struct { } type Heartbeat struct { - ID string `json:"id"` - Status string `json:"status"` - TS int64 `json:"ts"` + ID string `json:"id"` + InstanceID string `json:"instanceId"` + 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"` + InstanceID string `json:"instanceId,omitempty"` + 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/discovery/watcher.go b/api/pkg/discovery/watcher.go new file mode 100644 index 0000000..559196b --- /dev/null +++ b/api/pkg/discovery/watcher.go @@ -0,0 +1,126 @@ +package discovery + +import ( + "encoding/json" + "errors" + "sync" + "time" + + "github.com/nats-io/nats.go" + mb "github.com/tech/sendico/pkg/messaging/broker" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type RegistryWatcher struct { + logger mlogger.Logger + registry *Registry + kv *KVStore + watcher nats.KeyWatcher + + stopOnce sync.Once +} + +func NewRegistryWatcher(logger mlogger.Logger, msgBroker mb.Broker, registry *Registry) (*RegistryWatcher, error) { + if msgBroker == nil { + return nil, errors.New("discovery watcher: broker is nil") + } + if registry == nil { + registry = NewRegistry() + } + if logger != nil { + logger = logger.Named("discovery_watcher") + } + provider, ok := msgBroker.(jetStreamProvider) + if !ok { + return nil, errors.New("discovery watcher: jetstream not available") + } + js := provider.JetStream() + if js == nil { + return nil, errors.New("discovery watcher: jetstream not configured") + } + store, err := NewKVStore(logger, js, "") + if err != nil { + return nil, err + } + + return &RegistryWatcher{ + logger: logger, + registry: registry, + kv: store, + }, nil +} + +func (w *RegistryWatcher) Start() error { + if w == nil || w.kv == nil { + return errors.New("discovery watcher: not configured") + } + watcher, err := w.kv.WatchAll() + if err != nil { + return err + } + w.watcher = watcher + if w.logger != nil { + w.logger.Info("Discovery registry watcher started", zap.String("bucket", w.kv.Bucket())) + } + go w.consume(watcher) + return nil +} + +func (w *RegistryWatcher) Stop() { + if w == nil { + return + } + w.stopOnce.Do(func() { + if w.watcher != nil { + _ = w.watcher.Stop() + } + if w.logger != nil { + w.logger.Info("Discovery registry watcher stopped") + } + }) +} + +func (w *RegistryWatcher) Registry() *Registry { + if w == nil { + return nil + } + return w.registry +} + +func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) { + if w == nil || watcher == nil { + return + } + for entry := range watcher.Updates() { + if entry == nil { + continue + } + switch entry.Operation() { + case nats.KeyValueDelete, nats.KeyValuePurge: + key := registryKeyFromKVKey(entry.Key()) + if key != "" { + if w.registry.Delete(key) && w.logger != nil { + w.logger.Info("Discovery registry entry removed", zap.String("key", key)) + } + } + continue + case nats.KeyValuePut: + default: + continue + } + + var payload RegistryEntry + if err := json.Unmarshal(entry.Value(), &payload); err != nil { + if w.logger != nil { + w.logger.Warn("Failed to decode discovery KV entry", zap.String("key", entry.Key()), zap.Error(err)) + } + continue + } + result := w.registry.UpsertEntry(payload, time.Now()) + if w.logger != nil && (result.IsNew || result.BecameHealthy) { + fields := append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy)) + w.logger.Info("Discovery registry entry updated from KV", fields...) + } + } +} diff --git a/api/pkg/messaging/internal/natsb/broker.go b/api/pkg/messaging/internal/natsb/broker.go index b2e2b97..d23dd62 100644 --- a/api/pkg/messaging/internal/natsb/broker.go +++ b/api/pkg/messaging/internal/natsb/broker.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "strconv" + "strings" "sync" "time" @@ -20,6 +21,7 @@ type natsSubscriotions = map[string]*TopicSubscription type NatsBroker struct { nc *nats.Conn + js nats.JetStreamContext logger *zap.Logger topicSubs natsSubscriotions mu sync.Mutex @@ -78,23 +80,46 @@ func loadEnv(settings *nc.Settings, l *zap.Logger) (*envConfig, error) { func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, error) { l := logger.Named("broker") - // Helper function to get environment variables - cfg, err := loadEnv(settings, l) - if err != nil { - return nil, err + var err error + var cfg *envConfig + var natsURL string + if settings != nil && strings.TrimSpace(settings.URLEnv) != "" { + urlVal := strings.TrimSpace(os.Getenv(settings.URLEnv)) + if urlVal != "" { + natsURL = urlVal + } } + if natsURL == "" { + // Helper function to get environment variables + cfg, err = loadEnv(settings, l) + if err != nil { + return nil, err + } - u := &url.URL{ - Scheme: "nats", - Host: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), + u := &url.URL{ + Scheme: "nats", + Host: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), + } + natsURL = u.String() } - natsURL := u.String() opts := []nats.Option{ nats.Name(settings.NATSName), nats.MaxReconnects(settings.MaxReconnects), nats.ReconnectWait(time.Duration(settings.ReconnectWait) * time.Second), - nats.UserInfo(cfg.User, cfg.Password), + } + if cfg != nil { + opts = append(opts, nats.UserInfo(cfg.User, cfg.Password)) + } else if settings != nil { + userEnv := strings.TrimSpace(settings.UsernameEnv) + passEnv := strings.TrimSpace(settings.PasswordEnv) + if userEnv != "" && passEnv != "" { + user := strings.TrimSpace(os.Getenv(userEnv)) + pass := strings.TrimSpace(os.Getenv(passEnv)) + if user != "" || pass != "" { + opts = append(opts, nats.UserInfo(user, pass)) + } + } } res := &NatsBroker{ @@ -106,8 +131,18 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e l.Error("Failed to connect to NATS", zap.String("url", natsURL), zap.Error(err)) return nil, err } + if res.js, err = res.nc.JetStream(); err != nil { + l.Warn("Failed to initialise JetStream context", zap.Error(err)) + } logger.Info("Connected to NATS", zap.String("broker", settings.NATSName), - zap.String("url", fmt.Sprintf("nats://%s@%s", cfg.User, net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))))) + zap.String("url", natsURL)) return res, nil } + +func (b *NatsBroker) JetStream() nats.JetStreamContext { + if b == nil { + return nil + } + return b.js +} diff --git a/api/pkg/server/internal/server.go b/api/pkg/server/internal/server.go index 3160384..a31035b 100644 --- a/api/pkg/server/internal/server.go +++ b/api/pkg/server/internal/server.go @@ -7,6 +7,7 @@ import ( "os/signal" "syscall" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/mlogger" lf "github.com/tech/sendico/pkg/mlogger/factory" "github.com/tech/sendico/pkg/server" @@ -28,6 +29,7 @@ func prepareLogger() mlogger.Logger { func RunServer(rootLoggerName string, av version.Printer, factory server.ServerFactoryT) { logger := prepareLogger().Named(rootLoggerName) + logger = logger.With(zap.String("instance_id", discovery.InstanceID())) defer logger.Sync() // Show version information diff --git a/api/server/config.yml b/api/server/config.yml index a3bff14..d35cb8b 100755 --- a/api/server/config.yml +++ b/api/server/config.yml @@ -37,6 +37,7 @@ api: message_broker: driver: NATS settings: + url_env: NATS_URL host_env: NATS_HOST port_env: NATS_PORT username_env: NATS_USER @@ -54,7 +55,7 @@ api: length: 32 password: token_length: 32 - checks: + check: min_length: 8 digit: true upper: true diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index c8dbeb2..0042903 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -47,6 +47,12 @@ NATS_MONITORING_PORT=8222 NATS_PROMETHEUS_PORT=7777 NATS_COMPOSE_PROJECT=sendico-nats +# Discovery service +DISCOVERY_DIR=discovery +DISCOVERY_COMPOSE_PROJECT=sendico-discovery +DISCOVERY_SERVICE_NAME=sendico_discovery +DISCOVERY_METRICS_PORT=9405 + # Shared Mongo settings for FX services FX_MONGO_HOST=sendico_db1 diff --git a/ci/prod/compose/discovery.dockerfile b/ci/prod/compose/discovery.dockerfile new file mode 100644 index 0000000..1596126 --- /dev/null +++ b/ci/prod/compose/discovery.dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.7 + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +FROM golang:alpine AS build +ARG APP_VERSION=dev +ARG GIT_REV=unknown +ARG BUILD_BRANCH=unknown +ARG BUILD_DATE=unknown +ARG BUILD_USER=ci +ENV GO111MODULE=on +ENV PATH="/go/bin:${PATH}" +WORKDIR /src +COPY . . +RUN apk add --no-cache bash git build-base protoc protobuf-dev \ + && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \ + && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest \ + && bash ci/scripts/proto/generate.sh +WORKDIR /src/api/discovery +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "\ + -s -w \ + -X github.com/tech/sendico/discovery/internal/appversion.Version=${APP_VERSION} \ + -X github.com/tech/sendico/discovery/internal/appversion.Revision=${GIT_REV} \ + -X github.com/tech/sendico/discovery/internal/appversion.Branch=${BUILD_BRANCH} \ + -X github.com/tech/sendico/discovery/internal/appversion.BuildUser=${BUILD_USER} \ + -X github.com/tech/sendico/discovery/internal/appversion.BuildDate=${BUILD_DATE}" \ + -o /out/discovery . + +FROM alpine:latest AS runtime +RUN apk add --no-cache ca-certificates tzdata wget +WORKDIR /app +COPY api/discovery/config.yml /app/config.yml +COPY --from=build /out/discovery /app/discovery +EXPOSE 9405 +ENTRYPOINT ["/app/discovery"] +CMD ["--config.file", "/app/config.yml"] diff --git a/ci/prod/compose/discovery.yml b/ci/prod/compose/discovery.yml new file mode 100644 index 0000000..7bc3341 --- /dev/null +++ b/ci/prod/compose/discovery.yml @@ -0,0 +1,37 @@ +# Compose v2 - Discovery service + +x-common-env: &common-env + env_file: + - ../env/.env.runtime + - ../env/.env.version + +networks: + sendico-net: + external: true + name: sendico-net + +services: + sendico_discovery: + <<: *common-env + container_name: sendico-discovery + restart: unless-stopped + image: ${REGISTRY_URL}/discovery/service:${APP_V} + pull_policy: always + environment: + NATS_URL: ${NATS_URL} + NATS_HOST: ${NATS_HOST} + NATS_PORT: ${NATS_PORT} + NATS_USER: ${NATS_USER} + NATS_PASSWORD: ${NATS_PASSWORD} + DISCOVERY_METRICS_PORT: ${DISCOVERY_METRICS_PORT} + command: ["--config.file", "/app/config.yml"] + ports: + - "0.0.0.0:${DISCOVERY_METRICS_PORT}:${DISCOVERY_METRICS_PORT}" + healthcheck: + test: ["CMD-SHELL","wget -qO- http://localhost:${DISCOVERY_METRICS_PORT}/health | grep -q '\"status\":\"ok\"'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - sendico-net diff --git a/ci/prod/scripts/deploy/discovery.sh b/ci/prod/scripts/deploy/discovery.sh new file mode 100644 index 0000000..f9c2b4b --- /dev/null +++ b/ci/prod/scripts/deploy/discovery.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x +trap 'echo "[deploy-discovery] error at line $LINENO" >&2' ERR + +: "${REMOTE_BASE:?missing REMOTE_BASE}" +: "${SSH_USER:?missing SSH_USER}" +: "${SSH_HOST:?missing SSH_HOST}" +: "${DISCOVERY_DIR:?missing DISCOVERY_DIR}" +: "${DISCOVERY_COMPOSE_PROJECT:?missing DISCOVERY_COMPOSE_PROJECT}" +: "${DISCOVERY_SERVICE_NAME:?missing DISCOVERY_SERVICE_NAME}" + +REMOTE_DIR="${REMOTE_BASE%/}/${DISCOVERY_DIR}" +REMOTE_TARGET="${SSH_USER}@${SSH_HOST}" +COMPOSE_FILE="discovery.yml" +SERVICE_NAMES="${DISCOVERY_SERVICE_NAME}" + +REQUIRED_SECRETS=( + NATS_USER + NATS_PASSWORD + NATS_URL +) + +for var in "${REQUIRED_SECRETS[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "missing required secret env: ${var}" >&2 + exit 65 + fi +done + +if [[ ! -s .env.version ]]; then + echo ".env.version is missing; run version step first" >&2 + exit 66 +fi + +b64enc() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +NATS_USER_B64="$(b64enc "${NATS_USER}")" +NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")" +NATS_URL_B64="$(b64enc "${NATS_URL}")" + +SSH_OPTS=( + -i /root/.ssh/id_rsa + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o LogLevel=ERROR + -q +) +if [[ "${DEBUG_DEPLOY:-0}" = "1" ]]; then + SSH_OPTS=("${SSH_OPTS[@]/-q/}" -vv) +fi + +RSYNC_FLAGS=(-az --delete) +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && RSYNC_FLAGS=(-avz --delete) + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p ${REMOTE_DIR}/{compose,env}" + +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/compose/ "$REMOTE_TARGET:${REMOTE_DIR}/compose/" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/.env.runtime "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.runtime" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" .env.version "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.version" + +SERVICES_LINE="${SERVICE_NAMES}" + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \ + REMOTE_DIR="$REMOTE_DIR" \ + COMPOSE_FILE="$COMPOSE_FILE" \ + COMPOSE_PROJECT="$DISCOVERY_COMPOSE_PROJECT" \ + SERVICES_LINE="$SERVICES_LINE" \ + NATS_USER_B64="$NATS_USER_B64" \ + NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \ + NATS_URL_B64="$NATS_URL_B64" \ + bash -s <<'EOSSH' +set -euo pipefail +cd "${REMOTE_DIR}/compose" +set -a +. ../env/.env.runtime +load_kv_file() { + local file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then + local key="${line%%=*}" + local value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ -n "$key" ]]; then + export "$key=$value" + fi + fi + done <"$file" +} +load_kv_file ../env/.env.version +set +a + +if base64 -d >/dev/null 2>&1 <<<'AA=='; then + BASE64_DECODE_FLAG='-d' +else + BASE64_DECODE_FLAG='--decode' +fi + +decode_b64() { + val="$1" + if [[ -z "$val" ]]; then + printf '' + return + fi + printf '%s' "$val" | base64 "${BASE64_DECODE_FLAG}" +} + +NATS_USER="$(decode_b64 "$NATS_USER_B64")" +NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")" +NATS_URL="$(decode_b64 "$NATS_URL_B64")" + +export NATS_USER NATS_PASSWORD NATS_URL +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" +export COMPOSE_PROJECT_NAME +read -r -a SERVICES <<<"${SERVICES_LINE}" + +pull_cmd=(docker compose -f "$COMPOSE_FILE" pull) +up_cmd=(docker compose -f "$COMPOSE_FILE" up -d --remove-orphans) +ps_cmd=(docker compose -f "$COMPOSE_FILE" ps) +if [[ "${#SERVICES[@]}" -gt 0 ]]; then + pull_cmd+=("${SERVICES[@]}") + up_cmd+=("${SERVICES[@]}") + ps_cmd+=("${SERVICES[@]}") +fi + +"${pull_cmd[@]}" +"${up_cmd[@]}" +"${ps_cmd[@]}" + +date -Is > .last_deploy +logger -t "deploy-${COMPOSE_PROJECT_NAME}" "${COMPOSE_PROJECT_NAME} deployed at $(date -Is) in ${REMOTE_DIR}" +EOSSH diff --git a/ci/scripts/discovery/build-image.sh b/ci/scripts/discovery/build-image.sh new file mode 100644 index 0000000..20b9d62 --- /dev/null +++ b/ci/scripts/discovery/build-image.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +DISCOVERY_ENV_NAME="${DISCOVERY_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${DISCOVERY_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[discovery-build] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +REGISTRY_URL="${REGISTRY_URL:?missing REGISTRY_URL}" +APP_V="${APP_V:?missing APP_V}" +DISCOVERY_DOCKERFILE="${DISCOVERY_DOCKERFILE:?missing DISCOVERY_DOCKERFILE}" +DISCOVERY_IMAGE_PATH="${DISCOVERY_IMAGE_PATH:?missing DISCOVERY_IMAGE_PATH}" + +REGISTRY_HOST="${REGISTRY_URL#http://}" +REGISTRY_HOST="${REGISTRY_HOST#https://}" +REGISTRY_USER="$(cat secrets/REGISTRY_USER)" +REGISTRY_PASSWORD="$(cat secrets/REGISTRY_PASSWORD)" +: "${REGISTRY_USER:?missing registry user}" +: "${REGISTRY_PASSWORD:?missing registry password}" + +mkdir -p /kaniko/.docker +AUTH_B64="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" +cat </kaniko/.docker/config.json +{ + "auths": { + "https://${REGISTRY_HOST}": { "auth": "${AUTH_B64}" } + } +} +JSON + +BUILD_CONTEXT="${DISCOVERY_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}" +if [ ! -d "${BUILD_CONTEXT}" ]; then + BUILD_CONTEXT="/workspace" +fi + +/kaniko/executor \ + --context "${BUILD_CONTEXT}" \ + --dockerfile "${DISCOVERY_DOCKERFILE}" \ + --destination "${REGISTRY_URL}/${DISCOVERY_IMAGE_PATH}:${APP_V}" \ + --build-arg APP_VERSION="${APP_V}" \ + --build-arg GIT_REV="${GIT_REV}" \ + --build-arg BUILD_BRANCH="${BUILD_BRANCH}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg BUILD_USER="${BUILD_USER}" \ + --single-snapshot diff --git a/ci/scripts/discovery/deploy.sh b/ci/scripts/discovery/deploy.sh new file mode 100644 index 0000000..f120e17 --- /dev/null +++ b/ci/scripts/discovery/deploy.sh @@ -0,0 +1,57 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +DISCOVERY_ENV_NAME="${DISCOVERY_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${DISCOVERY_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[discovery-deploy] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +: "${NATS_HOST:?missing NATS_HOST}" +: "${NATS_PORT:?missing NATS_PORT}" + +export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" +export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)" +export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}" + +bash ci/prod/scripts/bootstrap/network.sh +bash ci/prod/scripts/deploy/discovery.sh -- 2.49.1 From 743f683d92f3ddc25578f74cdfa41160d7304df1 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 2 Jan 2026 14:54:18 +0100 Subject: [PATCH 3/4] TG settlement service --- api/gateway/chain/config.yml | 2 +- api/gateway/mntx/config.yml | 8 +- api/gateway/tgsettle/.gitignore | 1 + api/gateway/tgsettle/config.yml | 40 ++ api/gateway/tgsettle/go.mod | 51 +++ api/gateway/tgsettle/go.sum | 225 ++++++++++ .../tgsettle/internal/appversion/version.go | 27 ++ .../internal/server/internal/serverimp.go | 136 ++++++ .../tgsettle/internal/server/server.go | 11 + .../internal/service/gateway/service.go | 334 +++++++++++++++ .../internal/service/gateway/service_test.go | 289 +++++++++++++ api/gateway/tgsettle/main.go | 17 + .../tgsettle/storage/model/execution.go | 28 ++ .../tgsettle/storage/mongo/repository.go | 68 +++ .../tgsettle/storage/mongo/store/payments.go | 82 ++++ .../mongo/store/telegram_confirmations.go | 67 +++ api/gateway/tgsettle/storage/storage.go | 24 ++ api/notification/interface/api/api.go | 2 + api/notification/internal/api/api.go | 6 + .../server/notificationimp/confirmation.go | 404 ++++++++++++++++++ .../server/notificationimp/notification.go | 21 + .../server/notificationimp/telegram/client.go | 115 ++++- .../server/notificationimp/telegram/update.go | 50 +++ .../server/notificationimp/webhook.go | 30 ++ .../internal/server/internal/serverimp.go | 20 +- .../internal/server/internal/types.go | 2 + .../internal/service/orchestrator/convert.go | 63 +-- .../gateway_execution_consumer.go | 101 +++++ .../gateway_execution_consumer_test.go | 69 +++ .../service/orchestrator/handlers_commands.go | 15 +- .../service/orchestrator/handlers_events.go | 11 +- .../internal/service/orchestrator/helpers.go | 18 + .../service/orchestrator/internal_helpers.go | 33 +- .../internal/service/orchestrator/options.go | 9 + .../service/orchestrator/payment_executor.go | 6 +- .../orchestrator/payment_plan_executor.go | 50 +-- .../payment_plan_executor_test.go | 30 +- .../orchestrator/payment_plan_helpers.go | 56 +-- .../orchestrator/payment_plan_order.go | 193 +++++++++ .../service/orchestrator/plan_builder.go | 7 +- .../orchestrator/plan_builder_default.go | 18 +- .../orchestrator/plan_builder_default_test.go | 102 ++++- .../orchestrator/plan_builder_gateways.go | 17 +- .../orchestrator/plan_builder_plans.go | 26 +- .../orchestrator/plan_builder_routes.go | 160 +++---- .../orchestrator/plan_builder_steps.go | 272 ++++++------ .../orchestrator/plan_builder_templates.go | 128 ++++++ .../service/orchestrator/quote_engine.go | 32 +- .../service/orchestrator/quote_engine_test.go | 148 +++++++ .../orchestrator/quote_request_test.go | 3 +- .../internal/service/orchestrator/service.go | 6 + .../service/orchestrator/service_helpers.go | 3 + .../orchestrator/service_helpers_test.go | 112 ++++- .../service/orchestrator/service_test.go | 94 +++- .../orchestrator/storage/model/payment.go | 94 +++- .../storage/model/plan_template.go | 69 +++ .../orchestrator/storage/mongo/repository.go | 19 +- .../storage/mongo/store/plan_templates.go | 168 ++++++++ api/payments/orchestrator/storage/storage.go | 13 + api/pkg/discovery/service.go | 44 +- api/pkg/discovery/watcher.go | 13 + .../confirmations/notification.go | 77 ++++ .../notifications/confirmations/processor.go | 81 ++++ .../paymentgateway/notification.go | 66 +++ .../notifications/paymentgateway/processor.go | 81 ++++ .../confirmations/confirmations.go | 26 ++ .../confirmations/handler/interface.go | 11 + .../paymentgateway/handler/interface.go | 11 + .../paymentgateway/paymentgateway.go | 26 ++ api/pkg/model/confirmation.go | 60 +-- api/pkg/model/confirmation_code.go | 36 ++ api/pkg/model/notification/notification.go | 4 + api/pkg/model/notificationevent.go | 16 +- api/pkg/model/payment_gateway.go | 22 + api/pkg/mservice/services.go | 6 +- .../orchestrator/v1/orchestrator.proto | 9 + ci/prod/.env.runtime | 16 + ci/prod/compose/tgsettle_gateway.dockerfile | 40 ++ ci/prod/compose/tgsettle_gateway.yml | 53 +++ ci/prod/scripts/deploy/tgsettle_gateway.sh | 147 +++++++ ci/scripts/tgsettle/build-image.sh | 85 ++++ ci/scripts/tgsettle/deploy.sh | 61 +++ 82 files changed, 4693 insertions(+), 503 deletions(-) create mode 100644 api/gateway/tgsettle/.gitignore create mode 100644 api/gateway/tgsettle/config.yml create mode 100644 api/gateway/tgsettle/go.mod create mode 100644 api/gateway/tgsettle/go.sum create mode 100644 api/gateway/tgsettle/internal/appversion/version.go create mode 100644 api/gateway/tgsettle/internal/server/internal/serverimp.go create mode 100644 api/gateway/tgsettle/internal/server/server.go create mode 100644 api/gateway/tgsettle/internal/service/gateway/service.go create mode 100644 api/gateway/tgsettle/internal/service/gateway/service_test.go create mode 100644 api/gateway/tgsettle/main.go create mode 100644 api/gateway/tgsettle/storage/model/execution.go create mode 100644 api/gateway/tgsettle/storage/mongo/repository.go create mode 100644 api/gateway/tgsettle/storage/mongo/store/payments.go create mode 100644 api/gateway/tgsettle/storage/mongo/store/telegram_confirmations.go create mode 100644 api/gateway/tgsettle/storage/storage.go create mode 100644 api/notification/internal/server/notificationimp/confirmation.go create mode 100644 api/notification/internal/server/notificationimp/telegram/update.go create mode 100644 api/notification/internal/server/notificationimp/webhook.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go create mode 100644 api/payments/orchestrator/storage/model/plan_template.go create mode 100644 api/payments/orchestrator/storage/mongo/store/plan_templates.go create mode 100644 api/pkg/messaging/internal/notifications/confirmations/notification.go create mode 100644 api/pkg/messaging/internal/notifications/confirmations/processor.go create mode 100644 api/pkg/messaging/internal/notifications/paymentgateway/notification.go create mode 100644 api/pkg/messaging/internal/notifications/paymentgateway/processor.go create mode 100644 api/pkg/messaging/notifications/confirmations/confirmations.go create mode 100644 api/pkg/messaging/notifications/confirmations/handler/interface.go create mode 100644 api/pkg/messaging/notifications/paymentgateway/handler/interface.go create mode 100644 api/pkg/messaging/notifications/paymentgateway/paymentgateway.go create mode 100644 api/pkg/model/confirmation_code.go create mode 100644 api/pkg/model/payment_gateway.go create mode 100644 ci/prod/compose/tgsettle_gateway.dockerfile create mode 100644 ci/prod/compose/tgsettle_gateway.yml create mode 100755 ci/prod/scripts/deploy/tgsettle_gateway.sh create mode 100755 ci/scripts/tgsettle/build-image.sh create mode 100755 ci/scripts/tgsettle/deploy.sh diff --git a/api/gateway/chain/config.yml b/api/gateway/chain/config.yml index 0426314..3ad566a 100644 --- a/api/gateway/chain/config.yml +++ b/api/gateway/chain/config.yml @@ -8,7 +8,7 @@ grpc: enable_health: true metrics: - address: ":9403" + address: ":9406" database: driver: mongodb diff --git a/api/gateway/mntx/config.yml b/api/gateway/mntx/config.yml index ba2d1a4..2d14e11 100644 --- a/api/gateway/mntx/config.yml +++ b/api/gateway/mntx/config.yml @@ -35,10 +35,10 @@ monetix: gateway: id: "monetix" is_enabled: true - # network: "VISA_DIRECT" - # currencies: ["RUB"] - # limits: - # min_amount: "0" + network: "VISA_DIRECT" + currencies: ["RUB"] + limits: + min_amount: "0" http: callback: diff --git a/api/gateway/tgsettle/.gitignore b/api/gateway/tgsettle/.gitignore new file mode 100644 index 0000000..4cb3c7e --- /dev/null +++ b/api/gateway/tgsettle/.gitignore @@ -0,0 +1 @@ +/mntx-gateway diff --git a/api/gateway/tgsettle/config.yml b/api/gateway/tgsettle/config.yml new file mode 100644 index 0000000..7a10c6e --- /dev/null +++ b/api/gateway/tgsettle/config.yml @@ -0,0 +1,40 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50080" + enable_reflection: true + enable_health: true + +metrics: + address: ":9406" + +database: + driver: mongodb + settings: + host_env: TGSETTLE_GATEWAY_MONGO_HOST + port_env: TGSETTLE_GATEWAY_MONGO_PORT + database_env: TGSETTLE_GATEWAY_MONGO_DATABASE + user_env: TGSETTLE_GATEWAY_MONGO_USER + password_env: TGSETTLE_GATEWAY_MONGO_PASSWORD + auth_source_env: TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: TGSETTLE_GATEWAY_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: TGSettle Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + +gateway: + rail: "card" + target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID + timeout_seconds: 120 + accepted_user_ids: [] diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod new file mode 100644 index 0000000..fa9e07e --- /dev/null +++ b/api/gateway/tgsettle/go.mod @@ -0,0 +1,51 @@ +module github.com/tech/sendico/gateway/tgsettle + +go 1.25.3 + +replace github.com/tech/sendico/pkg => ../../pkg + +require ( + github.com/tech/sendico/pkg v0.1.0 + go.mongodb.org/mongo-driver v1.17.6 + go.uber.org/zap v1.27.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + 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/go-chi/chi/v5 v5.2.3 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nkeys v0.4.12 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum new file mode 100644 index 0000000..fbbbd30 --- /dev/null +++ b/api/gateway/tgsettle/go.sum @@ -0,0 +1,225 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +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= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E= +github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +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/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= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= +github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/gateway/tgsettle/internal/appversion/version.go b/api/gateway/tgsettle/internal/appversion/version.go new file mode 100644 index 0000000..3de8c14 --- /dev/null +++ b/api/gateway/tgsettle/internal/appversion/version.go @@ -0,0 +1,27 @@ +package appversion + +import ( + "github.com/tech/sendico/pkg/version" + vf "github.com/tech/sendico/pkg/version/factory" +) + +// Build information. Populated at build-time. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +func Create() version.Printer { + info := version.Info{ + Program: "Sendico Payment Gateway Service", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + return vf.Create(&info) +} diff --git a/api/gateway/tgsettle/internal/server/internal/serverimp.go b/api/gateway/tgsettle/internal/server/internal/serverimp.go new file mode 100644 index 0000000..53e2b4c --- /dev/null +++ b/api/gateway/tgsettle/internal/server/internal/serverimp.go @@ -0,0 +1,136 @@ +package serverimp + +import ( + "context" + "os" + "time" + + "github.com/tech/sendico/gateway/tgsettle/internal/service/gateway" + "github.com/tech/sendico/gateway/tgsettle/storage" + gatewaymongo "github.com/tech/sendico/gateway/tgsettle/storage/mongo" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + mb "github.com/tech/sendico/pkg/messaging/broker" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config + app *grpcapp.App[storage.Repository] + service *gateway.Service +} + +type config struct { + *grpcapp.Config `yaml:",inline"` + Gateway gatewayConfig `yaml:"gateway"` +} + +type gatewayConfig struct { + Rail string `yaml:"rail"` + TargetChatIDEnv string `yaml:"target_chat_id_env"` + TimeoutSeconds int32 `yaml:"timeout_seconds"` + AcceptedUserIDs []string `yaml:"accepted_user_ids"` +} + +func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { + return &Imp{ + logger: logger.Named("server"), + file: file, + debug: debug, + }, nil +} + +func (i *Imp) Shutdown() { + if i.app == nil { + return + } + timeout := 15 * time.Second + if i.config != nil && i.config.Runtime != nil { + timeout = i.config.Runtime.ShutdownTimeout() + } + if i.service != nil { + i.service.Shutdown() + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + i.app.Shutdown(ctx) +} + +func (i *Imp) Start() error { + cfg, err := i.loadConfig() + if err != nil { + return err + } + i.config = cfg + + var broker mb.Broker + if cfg.Messaging != nil && cfg.Messaging.Driver != "" { + broker, err = msg.CreateMessagingBroker(i.logger, cfg.Messaging) + if err != nil { + i.logger.Warn("Failed to create messaging broker", zap.Error(err)) + } + } + + repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { + return gatewaymongo.New(logger, conn) + } + + serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { + gwCfg := gateway.Config{ + Rail: cfg.Gateway.Rail, + TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv, + TimeoutSeconds: cfg.Gateway.TimeoutSeconds, + AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs, + } + svc := gateway.NewService(logger, repo, producer, broker, gwCfg) + i.service = svc + return svc, nil + } + + app, err := grpcapp.NewApp(i.logger, "tgsettle_gateway", cfg.Config, i.debug, repoFactory, serviceFactory) + if err != nil { + return err + } + i.app = app + return i.app.Start() +} + +func (i *Imp) loadConfig() (*config, error) { + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err + } + cfg := &config{Config: &grpcapp.Config{}} + if err := yaml.Unmarshal(data, cfg); err != nil { + i.logger.Error("Failed to parse configuration", zap.Error(err)) + return nil, err + } + if cfg.Runtime == nil { + cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} + } + if cfg.GRPC == nil { + cfg.GRPC = &routers.GRPCConfig{ + Network: "tcp", + Address: ":50080", + EnableReflection: true, + EnableHealth: true, + } + } + if cfg.Metrics == nil { + cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"} + } + if cfg.Gateway.Rail == "" { + return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail") + } + return cfg, nil +} diff --git a/api/gateway/tgsettle/internal/server/server.go b/api/gateway/tgsettle/internal/server/server.go new file mode 100644 index 0000000..7e56dd7 --- /dev/null +++ b/api/gateway/tgsettle/internal/server/server.go @@ -0,0 +1,11 @@ +package server + +import ( + serverimp "github.com/tech/sendico/gateway/tgsettle/internal/server/internal" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" +) + +func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return serverimp.Create(logger, file, debug) +} diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go new file mode 100644 index 0000000..ec4e5ad --- /dev/null +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -0,0 +1,334 @@ +package gateway + +import ( + "context" + "os" + "strings" + "sync" + + "github.com/tech/sendico/gateway/tgsettle/storage" + storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + mb "github.com/tech/sendico/pkg/messaging/broker" + cons "github.com/tech/sendico/pkg/messaging/consumer" + confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" + paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" +) + +const ( + defaultConfirmationTimeoutSeconds = 120 + executedStatus = "executed" +) + +type Config struct { + Rail string + TargetChatIDEnv string + TimeoutSeconds int32 + AcceptedUserIDs []string +} + +type Service struct { + logger mlogger.Logger + repo storage.Repository + producer msg.Producer + broker mb.Broker + cfg Config + rail string + chatID string + announcer *discovery.Announcer + + mu sync.Mutex + pending map[string]*model.PaymentGatewayIntent + consumers []msg.Consumer +} + +func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service { + if logger != nil { + logger = logger.Named("tgsettle_gateway") + } + svc := &Service{ + logger: logger, + repo: repo, + producer: producer, + broker: broker, + cfg: cfg, + rail: strings.TrimSpace(cfg.Rail), + pending: map[string]*model.PaymentGatewayIntent{}, + } + svc.chatID = strings.TrimSpace(readEnv(cfg.TargetChatIDEnv)) + svc.startConsumers() + svc.startAnnouncer() + return svc +} + +func (s *Service) Register(_ routers.GRPC) error { + return nil +} + +func (s *Service) Shutdown() { + if s == nil { + return + } + if s.announcer != nil { + s.announcer.Stop() + } + for _, consumer := range s.consumers { + if consumer != nil { + consumer.Close() + } + } +} + +func (s *Service) startConsumers() { + if s == nil || s.broker == nil { + if s != nil && s.logger != nil { + s.logger.Warn("Messaging broker not configured; confirmation flow disabled") + } + return + } + intentProcessor := paymentgateway.NewPaymentGatewayIntentProcessor(s.logger, s.onIntent) + s.consumeProcessor(intentProcessor) + resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult) + s.consumeProcessor(resultProcessor) +} + +func (s *Service) consumeProcessor(processor np.EnvelopeProcessor) { + consumer, err := cons.NewConsumer(s.logger, s.broker, processor.GetSubject()) + if err != nil { + s.logger.Warn("Failed to create messaging consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + return + } + s.consumers = append(s.consumers, consumer) + go func() { + if err := consumer.ConsumeMessages(processor.Process); err != nil { + s.logger.Warn("Messaging consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + } + }() +} + +func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayIntent) error { + if intent == nil { + return merrors.InvalidArgument("payment gateway intent is nil", "intent") + } + intent = normalizeIntent(intent) + if intent.IdempotencyKey == "" { + return merrors.InvalidArgument("idempotency_key is required", "idempotency_key") + } + if intent.PaymentIntentID == "" { + return merrors.InvalidArgument("payment_intent_id is required", "payment_intent_id") + } + if intent.RequestedMoney == nil || strings.TrimSpace(intent.RequestedMoney.Amount) == "" || strings.TrimSpace(intent.RequestedMoney.Currency) == "" { + return merrors.InvalidArgument("requested_money is required", "requested_money") + } + if s.repo == nil || s.repo.Payments() == nil { + return merrors.Internal("payment gateway storage unavailable") + } + + existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, intent.IdempotencyKey) + if err != nil { + return err + } + if existing != nil { + s.logger.Info("Payment gateway intent already executed", zap.String("idempotency_key", intent.IdempotencyKey)) + return nil + } + + confirmReq, err := s.buildConfirmationRequest(intent) + if err != nil { + return err + } + if err := s.sendConfirmationRequest(confirmReq); err != nil { + return err + } + s.trackIntent(confirmReq.RequestID, intent) + return nil +} + +func (s *Service) onConfirmationResult(ctx context.Context, result *model.ConfirmationResult) error { + if result == nil { + return merrors.InvalidArgument("confirmation result is nil", "result") + } + requestID := strings.TrimSpace(result.RequestID) + if requestID == "" { + return merrors.InvalidArgument("confirmation request_id is required", "request_id") + } + intent := s.lookupIntent(requestID) + if intent == nil { + s.logger.Warn("Confirmation result ignored: intent not found", zap.String("request_id", requestID)) + return nil + } + + if result.RawReply != nil && s.repo != nil && s.repo.TelegramConfirmations() != nil { + _ = s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{ + RequestID: requestID, + PaymentIntentID: intent.PaymentIntentID, + QuoteRef: intent.QuoteRef, + RawReply: result.RawReply, + }) + } + + if result.Status == model.ConfirmationStatusConfirmed || result.Status == model.ConfirmationStatusClarified { + exec := &storagemodel.PaymentExecution{ + IdempotencyKey: intent.IdempotencyKey, + PaymentIntentID: intent.PaymentIntentID, + ExecutedMoney: result.Money, + QuoteRef: intent.QuoteRef, + Status: executedStatus, + } + if err := s.repo.Payments().InsertExecution(ctx, exec); err != nil && err != storage.ErrDuplicate { + return err + } + } + + s.publishExecution(intent, result) + s.removeIntent(requestID) + return nil +} + +func (s *Service) buildConfirmationRequest(intent *model.PaymentGatewayIntent) (*model.ConfirmationRequest, error) { + targetChatID := strings.TrimSpace(intent.TargetChatID) + if targetChatID == "" { + targetChatID = s.chatID + } + if targetChatID == "" { + return nil, merrors.InvalidArgument("target_chat_id is required", "target_chat_id") + } + rail := strings.TrimSpace(intent.OutgoingLeg) + if rail == "" { + rail = s.rail + } + timeout := s.cfg.TimeoutSeconds + if timeout <= 0 { + timeout = int32(defaultConfirmationTimeoutSeconds) + } + return &model.ConfirmationRequest{ + RequestID: intent.IdempotencyKey, + TargetChatID: targetChatID, + RequestedMoney: intent.RequestedMoney, + PaymentIntentID: intent.PaymentIntentID, + QuoteRef: intent.QuoteRef, + AcceptedUserIDs: s.cfg.AcceptedUserIDs, + TimeoutSeconds: timeout, + SourceService: string(mservice.PaymentGateway), + Rail: rail, + }, nil +} + +func (s *Service) sendConfirmationRequest(request *model.ConfirmationRequest) error { + if request == nil { + return merrors.InvalidArgument("confirmation request is nil", "request") + } + if s.producer == nil { + return merrors.Internal("messaging producer is not configured") + } + env := confirmations.ConfirmationRequest(string(mservice.PaymentGateway), request) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("request_id", request.RequestID)) + return err + } + return nil +} + +func (s *Service) publishExecution(intent *model.PaymentGatewayIntent, result *model.ConfirmationResult) { + if s == nil || intent == nil || result == nil || s.producer == nil { + return + } + exec := &model.PaymentGatewayExecution{ + PaymentIntentID: intent.PaymentIntentID, + IdempotencyKey: intent.IdempotencyKey, + QuoteRef: intent.QuoteRef, + ExecutedMoney: result.Money, + Status: result.Status, + RequestID: result.RequestID, + RawReply: result.RawReply, + } + env := paymentgateway.PaymentGatewayExecution(string(mservice.PaymentGateway), exec) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish gateway execution result", zap.Error(err), zap.String("request_id", result.RequestID)) + } +} + +func (s *Service) trackIntent(requestID string, intent *model.PaymentGatewayIntent) { + if s == nil || intent == nil { + return + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return + } + s.mu.Lock() + s.pending[requestID] = intent + s.mu.Unlock() +} + +func (s *Service) lookupIntent(requestID string) *model.PaymentGatewayIntent { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.pending[requestID] +} + +func (s *Service) removeIntent(requestID string) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return + } + s.mu.Lock() + delete(s.pending, requestID) + s.mu.Unlock() +} + +func (s *Service) startAnnouncer() { + if s == nil || s.producer == nil { + return + } + caps := []string{"telegram_confirmation", "money_persistence"} + if s.rail != "" { + caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail)) + } + announce := discovery.Announcement{ + Service: string(mservice.PaymentGateway), + Rail: s.rail, + Operations: caps, + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.PaymentGateway), announce) + s.announcer.Start() +} + +func normalizeIntent(intent *model.PaymentGatewayIntent) *model.PaymentGatewayIntent { + if intent == nil { + return nil + } + cp := *intent + cp.PaymentIntentID = strings.TrimSpace(cp.PaymentIntentID) + cp.IdempotencyKey = strings.TrimSpace(cp.IdempotencyKey) + cp.OutgoingLeg = strings.TrimSpace(cp.OutgoingLeg) + cp.QuoteRef = strings.TrimSpace(cp.QuoteRef) + cp.TargetChatID = strings.TrimSpace(cp.TargetChatID) + if cp.RequestedMoney != nil { + cp.RequestedMoney.Amount = strings.TrimSpace(cp.RequestedMoney.Amount) + cp.RequestedMoney.Currency = strings.TrimSpace(cp.RequestedMoney.Currency) + } + return &cp +} + +func readEnv(env string) string { + if strings.TrimSpace(env) == "" { + return "" + } + return strings.TrimSpace(os.Getenv(env)) +} + +var _ grpcapp.Service = (*Service)(nil) diff --git a/api/gateway/tgsettle/internal/service/gateway/service_test.go b/api/gateway/tgsettle/internal/service/gateway/service_test.go new file mode 100644 index 0000000..6691422 --- /dev/null +++ b/api/gateway/tgsettle/internal/service/gateway/service_test.go @@ -0,0 +1,289 @@ +package gateway + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "github.com/tech/sendico/gateway/tgsettle/storage" + storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" + envelope "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/model" + notification "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" + mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +type fakePaymentsStore struct { + mu sync.Mutex + executions map[string]*storagemodel.PaymentExecution +} + +func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentExecution, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.executions[key], nil +} + +func (f *fakePaymentsStore) InsertExecution(_ context.Context, exec *storagemodel.PaymentExecution) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.executions == nil { + f.executions = map[string]*storagemodel.PaymentExecution{} + } + if _, ok := f.executions[exec.IdempotencyKey]; ok { + return storage.ErrDuplicate + } + f.executions[exec.IdempotencyKey] = exec + return nil +} + +type fakeTelegramStore struct { + mu sync.Mutex + records map[string]*storagemodel.TelegramConfirmation +} + +func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.TelegramConfirmation) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.records == nil { + f.records = map[string]*storagemodel.TelegramConfirmation{} + } + f.records[record.RequestID] = record + return nil +} + +type fakeRepo struct { + payments *fakePaymentsStore + tg *fakeTelegramStore +} + +func (f *fakeRepo) Payments() storage.PaymentsStore { + return f.payments +} + +func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore { + return f.tg +} + +type captureProducer struct { + mu sync.Mutex + confirmationRequests []*model.ConfirmationRequest + executions []*model.PaymentGatewayExecution +} + +func (c *captureProducer) SendMessage(env envelope.Envelope) error { + _, _ = env.Serialize() + switch env.GetSignature().ToString() { + case model.NewNotification(mservice.Notifications, notification.NAConfirmationRequest).ToString(): + var req model.ConfirmationRequest + if err := json.Unmarshal(env.GetData(), &req); err == nil { + c.mu.Lock() + c.confirmationRequests = append(c.confirmationRequests, &req) + c.mu.Unlock() + } + case model.NewNotification(mservice.PaymentGateway, notification.NAPaymentGatewayExecution).ToString(): + var exec model.PaymentGatewayExecution + if err := json.Unmarshal(env.GetData(), &exec); err == nil { + c.mu.Lock() + c.executions = append(c.executions, &exec) + c.mu.Unlock() + } + } + return nil +} + +func (c *captureProducer) Reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.confirmationRequests = nil + c.executions = nil +} + +func TestOnIntentCreatesConfirmationRequest(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + t.Setenv("PGS_CHAT_ID", "-100") + svc := NewService(logger, repo, prod, nil, Config{ + Rail: "card", + TargetChatIDEnv: "PGS_CHAT_ID", + TimeoutSeconds: 90, + AcceptedUserIDs: []string{"42"}, + }) + prod.Reset() + + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-1", + IdempotencyKey: "idem-1", + OutgoingLeg: "card", + QuoteRef: "quote-1", + RequestedMoney: &paymenttypes.Money{Amount: "10.50", Currency: "USD"}, + TargetChatID: "", + } + if err := svc.onIntent(context.Background(), intent); err != nil { + t.Fatalf("onIntent error: %v", err) + } + if len(prod.confirmationRequests) != 1 { + t.Fatalf("expected 1 confirmation request, got %d", len(prod.confirmationRequests)) + } + req := prod.confirmationRequests[0] + if req.RequestID != "idem-1" || req.PaymentIntentID != "pi-1" || req.QuoteRef != "quote-1" { + t.Fatalf("unexpected confirmation request fields: %#v", req) + } + if req.TargetChatID != "-100" { + t.Fatalf("expected target chat id -100, got %q", req.TargetChatID) + } + if req.RequestedMoney == nil || req.RequestedMoney.Amount != "10.50" || req.RequestedMoney.Currency != "USD" { + t.Fatalf("requested money mismatch: %#v", req.RequestedMoney) + } + if req.TimeoutSeconds != 90 { + t.Fatalf("expected timeout 90, got %d", req.TimeoutSeconds) + } + if req.SourceService != string(mservice.PaymentGateway) || req.Rail != "card" { + t.Fatalf("unexpected source/rail: %#v", req) + } +} + +func TestConfirmationResultPersistsExecutionAndReply(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-2", + IdempotencyKey: "idem-2", + QuoteRef: "quote-2", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + } + svc.trackIntent("idem-2", intent) + + result := &model.ConfirmationResult{ + RequestID: "idem-2", + Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + Status: model.ConfirmationStatusConfirmed, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2", Text: "5 EUR"}, + } + if err := svc.onConfirmationResult(context.Background(), result); err != nil { + t.Fatalf("onConfirmationResult error: %v", err) + } + if repo.payments.executions["idem-2"] == nil { + t.Fatalf("expected payment execution to be stored") + } + if repo.payments.executions["idem-2"].ExecutedMoney == nil || repo.payments.executions["idem-2"].ExecutedMoney.Amount != "5" { + t.Fatalf("executed money not stored correctly") + } + if repo.tg.records["idem-2"] == nil || repo.tg.records["idem-2"].RawReply.Text != "5 EUR" { + t.Fatalf("telegram reply not stored correctly") + } +} + +func TestClarifiedResultPersistsExecution(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-clarified", + IdempotencyKey: "idem-clarified", + QuoteRef: "quote-clarified", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "12", Currency: "USD"}, + } + svc.trackIntent("idem-clarified", intent) + + result := &model.ConfirmationResult{ + RequestID: "idem-clarified", + Money: &paymenttypes.Money{Amount: "12", Currency: "USD"}, + Status: model.ConfirmationStatusClarified, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "3", Text: "12 USD"}, + } + if err := svc.onConfirmationResult(context.Background(), result); err != nil { + t.Fatalf("onConfirmationResult error: %v", err) + } + if repo.payments.executions["idem-clarified"] == nil { + t.Fatalf("expected payment execution to be stored") + } +} + +func TestIdempotencyPreventsDuplicateWrites(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{executions: map[string]*storagemodel.PaymentExecution{ + "idem-3": {IdempotencyKey: "idem-3"}, + }}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-3", + IdempotencyKey: "idem-3", + OutgoingLeg: "card", + QuoteRef: "quote-3", + RequestedMoney: &paymenttypes.Money{Amount: "1", Currency: "USD"}, + TargetChatID: "chat", + } + if err := svc.onIntent(context.Background(), intent); err != nil { + t.Fatalf("onIntent error: %v", err) + } + if len(prod.confirmationRequests) != 0 { + t.Fatalf("expected no confirmation request for duplicate intent") + } +} + +func TestTimeoutDoesNotPersistExecution(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-4", + IdempotencyKey: "idem-4", + QuoteRef: "quote-4", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "8", Currency: "USD"}, + } + svc.trackIntent("idem-4", intent) + + result := &model.ConfirmationResult{ + RequestID: "idem-4", + Status: model.ConfirmationStatusTimeout, + } + if err := svc.onConfirmationResult(context.Background(), result); err != nil { + t.Fatalf("onConfirmationResult error: %v", err) + } + if repo.payments.executions["idem-4"] != nil { + t.Fatalf("expected no execution record for timeout") + } +} + +func TestRejectedDoesNotPersistExecution(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-reject", + IdempotencyKey: "idem-reject", + QuoteRef: "quote-reject", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "3", Currency: "USD"}, + } + svc.trackIntent("idem-reject", intent) + + result := &model.ConfirmationResult{ + RequestID: "idem-reject", + Status: model.ConfirmationStatusRejected, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "4", Text: "no"}, + } + if err := svc.onConfirmationResult(context.Background(), result); err != nil { + t.Fatalf("onConfirmationResult error: %v", err) + } + if repo.payments.executions["idem-reject"] != nil { + t.Fatalf("expected no execution record for rejection") + } + if repo.tg.records["idem-reject"] == nil { + t.Fatalf("expected raw reply to be stored for rejection") + } +} diff --git a/api/gateway/tgsettle/main.go b/api/gateway/tgsettle/main.go new file mode 100644 index 0000000..29cada1 --- /dev/null +++ b/api/gateway/tgsettle/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/gateway/tgsettle/internal/appversion" + si "github.com/tech/sendico/gateway/tgsettle/internal/server" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" +) + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("gateway", appversion.Create(), factory) +} diff --git a/api/gateway/tgsettle/storage/model/execution.go b/api/gateway/tgsettle/storage/model/execution.go new file mode 100644 index 0000000..672e4f4 --- /dev/null +++ b/api/gateway/tgsettle/storage/model/execution.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type PaymentExecution struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` +} + +type TelegramConfirmation struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"` + ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"` +} diff --git a/api/gateway/tgsettle/storage/mongo/repository.go b/api/gateway/tgsettle/storage/mongo/repository.go new file mode 100644 index 0000000..828b699 --- /dev/null +++ b/api/gateway/tgsettle/storage/mongo/repository.go @@ -0,0 +1,68 @@ +package mongo + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/tgsettle/storage" + "github.com/tech/sendico/gateway/tgsettle/storage/mongo/store" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +type Repository struct { + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + + payments storage.PaymentsStore + tg storage.TelegramConfirmationsStore +} + +func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { + if conn == nil { + return nil, merrors.InvalidArgument("mongo connection is nil") + } + client := conn.Client() + if client == nil { + return nil, merrors.Internal("mongo client is not initialised") + } + result := &Repository{ + logger: logger.Named("storage").Named("mongo"), + conn: conn, + db: conn.Database(), + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := result.conn.Ping(ctx); err != nil { + result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err)) + return nil, err + } + paymentsStore, err := store.NewPayments(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise payments store", zap.Error(err)) + return nil, err + } + tgStore, err := store.NewTelegramConfirmations(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err)) + return nil, err + } + result.payments = paymentsStore + result.tg = tgStore + result.logger.Info("Payment gateway MongoDB storage initialised") + return result, nil +} + +func (r *Repository) Payments() storage.PaymentsStore { + return r.payments +} + +func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore { + return r.tg +} + +var _ storage.Repository = (*Repository)(nil) diff --git a/api/gateway/tgsettle/storage/mongo/store/payments.go b/api/gateway/tgsettle/storage/mongo/store/payments.go new file mode 100644 index 0000000..8d97114 --- /dev/null +++ b/api/gateway/tgsettle/storage/mongo/store/payments.go @@ -0,0 +1,82 @@ +package store + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/gateway/tgsettle/storage" + "github.com/tech/sendico/gateway/tgsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.uber.org/zap" +) + +const ( + paymentsCollection = "payments" + fieldIdempotencyKey = "idempotencyKey" +) + +type Payments struct { + logger mlogger.Logger + coll *mongo.Collection +} + +func NewPayments(logger mlogger.Logger, db *mongo.Database) (*Payments, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + p := &Payments{ + logger: logger.Named("payments"), + coll: db.Collection(paymentsCollection), + } + _, err := p.coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{{Key: fieldIdempotencyKey, Value: 1}}, + Options: options.Index().SetUnique(true), + }) + if err != nil { + p.logger.Error("Failed to create payments idempotency index", zap.Error(err)) + return nil, err + } + return p, nil +} + +func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentExecution, error) { + key = strings.TrimSpace(key) + if key == "" { + return nil, merrors.InvalidArgument("idempotency key is required", "idempotency_key") + } + var result model.PaymentExecution + err := p.coll.FindOne(ctx, bson.M{fieldIdempotencyKey: key}).Decode(&result) + if err == mongo.ErrNoDocuments { + return nil, nil + } + if err != nil { + return nil, err + } + return &result, nil +} + +func (p *Payments) InsertExecution(ctx context.Context, exec *model.PaymentExecution) error { + if exec == nil { + return merrors.InvalidArgument("payment execution is nil", "execution") + } + exec.IdempotencyKey = strings.TrimSpace(exec.IdempotencyKey) + exec.PaymentIntentID = strings.TrimSpace(exec.PaymentIntentID) + exec.QuoteRef = strings.TrimSpace(exec.QuoteRef) + if exec.ExecutedAt.IsZero() { + exec.ExecutedAt = time.Now() + } + if _, err := p.coll.InsertOne(ctx, exec); err != nil { + if mongo.IsDuplicateKeyError(err) { + return storage.ErrDuplicate + } + return err + } + return nil +} + +var _ storage.PaymentsStore = (*Payments)(nil) diff --git a/api/gateway/tgsettle/storage/mongo/store/telegram_confirmations.go b/api/gateway/tgsettle/storage/mongo/store/telegram_confirmations.go new file mode 100644 index 0000000..b38eb70 --- /dev/null +++ b/api/gateway/tgsettle/storage/mongo/store/telegram_confirmations.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/gateway/tgsettle/storage" + "github.com/tech/sendico/gateway/tgsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.uber.org/zap" +) + +const ( + telegramCollection = "telegram_confirmations" + fieldRequestID = "requestId" +) + +type TelegramConfirmations struct { + logger mlogger.Logger + coll *mongo.Collection +} + +func NewTelegramConfirmations(logger mlogger.Logger, db *mongo.Database) (*TelegramConfirmations, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + t := &TelegramConfirmations{ + logger: logger.Named("telegram_confirmations"), + coll: db.Collection(telegramCollection), + } + _, err := t.coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{{Key: fieldRequestID, Value: 1}}, + Options: options.Index().SetUnique(true), + }) + if err != nil { + t.logger.Error("Failed to create telegram confirmations request_id index", zap.Error(err)) + return nil, err + } + return t, nil +} + +func (t *TelegramConfirmations) Upsert(ctx context.Context, record *model.TelegramConfirmation) error { + if record == nil { + return merrors.InvalidArgument("telegram confirmation is nil", "record") + } + record.RequestID = strings.TrimSpace(record.RequestID) + record.PaymentIntentID = strings.TrimSpace(record.PaymentIntentID) + record.QuoteRef = strings.TrimSpace(record.QuoteRef) + if record.RequestID == "" { + return merrors.InvalidArgument("request_id is required", "request_id") + } + if record.ReceivedAt.IsZero() { + record.ReceivedAt = time.Now() + } + update := bson.M{ + "$set": record, + } + _, err := t.coll.UpdateOne(ctx, bson.M{fieldRequestID: record.RequestID}, update, options.Update().SetUpsert(true)) + return err +} + +var _ storage.TelegramConfirmationsStore = (*TelegramConfirmations)(nil) diff --git a/api/gateway/tgsettle/storage/storage.go b/api/gateway/tgsettle/storage/storage.go new file mode 100644 index 0000000..598bbee --- /dev/null +++ b/api/gateway/tgsettle/storage/storage.go @@ -0,0 +1,24 @@ +package storage + +import ( + "context" + "errors" + + "github.com/tech/sendico/gateway/tgsettle/storage/model" +) + +var ErrDuplicate = errors.New("payment gateway storage: duplicate record") + +type Repository interface { + Payments() PaymentsStore + TelegramConfirmations() TelegramConfirmationsStore +} + +type PaymentsStore interface { + FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentExecution, error) + InsertExecution(ctx context.Context, exec *model.PaymentExecution) error +} + +type TelegramConfirmationsStore interface { + Upsert(ctx context.Context, record *model.TelegramConfirmation) error +} diff --git a/api/notification/interface/api/api.go b/api/notification/interface/api/api.go index 8410088..6941441 100644 --- a/api/notification/interface/api/api.go +++ b/api/notification/interface/api/api.go @@ -1,6 +1,7 @@ package api import ( + "github.com/go-chi/chi/v5" "github.com/tech/sendico/notification/interface/api/localizer" "github.com/tech/sendico/pkg/db" "github.com/tech/sendico/pkg/domainprovider" @@ -16,6 +17,7 @@ type API interface { Register() messaging.Register Localizer() localizer.Localizer DomainProvider() domainprovider.DomainProvider + Router() *chi.Mux } type MicroServiceFactoryT = func(API) (mservice.MicroService, error) diff --git a/api/notification/internal/api/api.go b/api/notification/internal/api/api.go index 4beea9c..6999eda 100644 --- a/api/notification/internal/api/api.go +++ b/api/notification/internal/api/api.go @@ -27,6 +27,7 @@ type APIImp struct { services Microservices debug bool mw *Middleware + router *chi.Mux } func (a *APIImp) installMicroservice(srv mservice.MicroService) { @@ -69,6 +70,10 @@ func (a *APIImp) Register() messaging.Register { return a.mw } +func (a *APIImp) Router() *chi.Mux { + return a.router +} + func (a *APIImp) installServices() error { srvf := make([]api.MicroServiceFactoryT, 0) @@ -117,6 +122,7 @@ func CreateAPI(logger mlogger.Logger, config *api.Config, l localizer.Localizer, p.config = config p.db = db p.localizer = l + p.router = router var err error if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil { diff --git a/api/notification/internal/server/notificationimp/confirmation.go b/api/notification/internal/server/notificationimp/confirmation.go new file mode 100644 index 0000000..f3e8782 --- /dev/null +++ b/api/notification/internal/server/notificationimp/confirmation.go @@ -0,0 +1,404 @@ +package notificationimp + +import ( + "context" + "errors" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/tech/sendico/notification/internal/server/notificationimp/telegram" + msg "github.com/tech/sendico/pkg/messaging" + confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.uber.org/zap" +) + +const ( + defaultConfirmationTimeout = 120 * time.Second +) + +type confirmationManager struct { + logger mlogger.Logger + tg telegram.Client + sender string + outbox msg.Producer + + mu sync.Mutex + pendingByMessage map[string]*confirmationState + pendingByRequest map[string]*confirmationState +} + +type confirmationState struct { + request model.ConfirmationRequest + requestMessageID string + targetChatID string + callbackSubject string + clarified bool + timer *time.Timer +} + +func newConfirmationManager(logger mlogger.Logger, tg telegram.Client, outbox msg.Producer) *confirmationManager { + if logger != nil { + logger = logger.Named("confirmations") + } + return &confirmationManager{ + logger: logger, + tg: tg, + outbox: outbox, + sender: string(mservice.Notifications), + pendingByMessage: map[string]*confirmationState{}, + pendingByRequest: map[string]*confirmationState{}, + } +} + +func (m *confirmationManager) Stop() { + if m == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + for _, state := range m.pendingByMessage { + if state.timer != nil { + state.timer.Stop() + } + } + m.pendingByMessage = map[string]*confirmationState{} + m.pendingByRequest = map[string]*confirmationState{} +} + +func (m *confirmationManager) HandleRequest(ctx context.Context, request *model.ConfirmationRequest) error { + if m == nil { + return errors.New("confirmation manager is nil") + } + if request == nil { + return merrors.InvalidArgument("confirmation request is nil", "request") + } + if m.tg == nil { + return merrors.InvalidArgument("telegram client is not configured", "telegram") + } + + req := normalizeConfirmationRequest(*request) + if req.RequestID == "" { + return merrors.InvalidArgument("confirmation request_id is required", "request_id") + } + if req.TargetChatID == "" { + return merrors.InvalidArgument("confirmation target_chat_id is required", "target_chat_id") + } + if req.RequestedMoney == nil || strings.TrimSpace(req.RequestedMoney.Amount) == "" || strings.TrimSpace(req.RequestedMoney.Currency) == "" { + return merrors.InvalidArgument("confirmation requested_money is required", "requested_money") + } + if req.SourceService == "" { + return merrors.InvalidArgument("confirmation source_service is required", "source_service") + } + + m.mu.Lock() + if _, ok := m.pendingByRequest[req.RequestID]; ok { + m.mu.Unlock() + m.logger.Info("Confirmation request already pending", zap.String("request_id", req.RequestID)) + return nil + } + m.mu.Unlock() + + message := confirmationPrompt(&req) + sent, err := m.tg.SendText(ctx, req.TargetChatID, message, "") + if err != nil { + m.logger.Warn("Failed to send confirmation request to Telegram", zap.Error(err), zap.String("request_id", req.RequestID)) + return err + } + if sent == nil || strings.TrimSpace(sent.MessageID) == "" { + return merrors.Internal("telegram confirmation message id is missing") + } + + state := &confirmationState{ + request: req, + requestMessageID: strings.TrimSpace(sent.MessageID), + targetChatID: strings.TrimSpace(req.TargetChatID), + callbackSubject: confirmationCallbackSubject(req.SourceService, req.Rail), + } + timeout := time.Duration(req.TimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = defaultConfirmationTimeout + } + state.timer = time.AfterFunc(timeout, func() { + m.handleTimeout(state.requestMessageID) + }) + + m.mu.Lock() + m.pendingByMessage[state.requestMessageID] = state + m.pendingByRequest[req.RequestID] = state + m.mu.Unlock() + + m.logger.Info("Confirmation request sent", zap.String("request_id", req.RequestID), zap.String("message_id", state.requestMessageID), zap.String("callback_subject", state.callbackSubject)) + return nil +} + +func (m *confirmationManager) HandleUpdate(ctx context.Context, update *telegram.Update) { + if m == nil || update == nil || update.Message == nil { + return + } + message := update.Message + if message.ReplyToMessage == nil { + return + } + + replyToID := strconv.FormatInt(message.ReplyToMessage.MessageID, 10) + state := m.lookupByMessageID(replyToID) + if state == nil { + return + } + + chatID := strconv.FormatInt(message.Chat.ID, 10) + if chatID != state.targetChatID { + m.logger.Debug("Telegram reply ignored: chat mismatch", zap.String("expected_chat_id", state.targetChatID), zap.String("chat_id", chatID)) + return + } + + rawReply := message.ToModel() + if !state.isUserAllowed(message.From) { + m.publishResult(state, &model.ConfirmationResult{ + RequestID: state.request.RequestID, + Status: model.ConfirmationStatusRejected, + ParseError: "unauthorized_user", + RawReply: rawReply, + }) + m.sendNotice(ctx, state, rawReply, "Only approved users can confirm this payment.") + m.removeState(state.requestMessageID) + return + } + + money, reason, err := parseConfirmationReply(message.Text) + if err != nil { + m.mu.Lock() + state.clarified = true + m.mu.Unlock() + m.sendNotice(ctx, state, rawReply, clarificationMessage(reason)) + return + } + + m.mu.Lock() + clarified := state.clarified + m.mu.Unlock() + status := model.ConfirmationStatusConfirmed + if clarified { + status = model.ConfirmationStatusClarified + } + m.publishResult(state, &model.ConfirmationResult{ + RequestID: state.request.RequestID, + Money: money, + RawReply: rawReply, + Status: status, + }) + m.removeState(state.requestMessageID) +} + +func (m *confirmationManager) lookupByMessageID(messageID string) *confirmationState { + m.mu.Lock() + defer m.mu.Unlock() + return m.pendingByMessage[strings.TrimSpace(messageID)] +} + +func (m *confirmationManager) handleTimeout(messageID string) { + state := m.lookupByMessageID(messageID) + if state == nil { + return + } + m.publishResult(state, &model.ConfirmationResult{ + RequestID: state.request.RequestID, + Status: model.ConfirmationStatusTimeout, + }) + m.removeState(messageID) +} + +func (m *confirmationManager) removeState(messageID string) { + messageID = strings.TrimSpace(messageID) + if messageID == "" { + return + } + m.mu.Lock() + state := m.pendingByMessage[messageID] + if state != nil && state.timer != nil { + state.timer.Stop() + } + delete(m.pendingByMessage, messageID) + if state != nil { + delete(m.pendingByRequest, state.request.RequestID) + } + m.mu.Unlock() +} + +func (m *confirmationManager) publishResult(state *confirmationState, result *model.ConfirmationResult) { + if m == nil || state == nil || result == nil { + return + } + if m.outbox == nil { + m.logger.Warn("Confirmation result skipped: producer not configured", zap.String("request_id", state.request.RequestID)) + return + } + env := confirmations.ConfirmationResult(m.sender, result, state.request.SourceService, state.request.Rail) + if err := m.outbox.SendMessage(env); err != nil { + m.logger.Warn("Failed to publish confirmation result", zap.Error(err), zap.String("request_id", state.request.RequestID)) + return + } + m.logger.Info("Confirmation result published", zap.String("request_id", state.request.RequestID), zap.String("status", string(result.Status))) +} + +func (m *confirmationManager) sendNotice(ctx context.Context, state *confirmationState, reply *model.TelegramMessage, text string) { + if m == nil || m.tg == nil || state == nil { + return + } + replyID := "" + if reply != nil { + replyID = reply.MessageID + } + if _, err := m.tg.SendText(ctx, state.targetChatID, text, replyID); err != nil { + m.logger.Warn("Failed to send clarification notice", zap.Error(err), zap.String("request_id", state.request.RequestID)) + } +} + +func (s *confirmationState) isUserAllowed(user *telegram.User) bool { + if s == nil { + return false + } + allowed := s.request.AcceptedUserIDs + if len(allowed) == 0 { + return true + } + if user == nil { + return false + } + userID := strconv.FormatInt(user.ID, 10) + for _, id := range allowed { + if id == userID { + return true + } + } + return false +} + +func confirmationCallbackSubject(sourceService, rail string) string { + sourceService = strings.ToLower(strings.TrimSpace(sourceService)) + if sourceService == "" { + sourceService = "unknown" + } + rail = strings.ToLower(strings.TrimSpace(rail)) + if rail == "" { + rail = "default" + } + return "confirmations." + sourceService + "." + rail +} + +func normalizeConfirmationRequest(request model.ConfirmationRequest) model.ConfirmationRequest { + request.RequestID = strings.TrimSpace(request.RequestID) + request.TargetChatID = strings.TrimSpace(request.TargetChatID) + request.PaymentIntentID = strings.TrimSpace(request.PaymentIntentID) + request.QuoteRef = strings.TrimSpace(request.QuoteRef) + request.SourceService = strings.TrimSpace(request.SourceService) + request.Rail = strings.TrimSpace(request.Rail) + request.AcceptedUserIDs = normalizeStringList(request.AcceptedUserIDs) + if request.RequestedMoney != nil { + request.RequestedMoney.Amount = strings.TrimSpace(request.RequestedMoney.Amount) + request.RequestedMoney.Currency = strings.TrimSpace(request.RequestedMoney.Currency) + } + return request +} + +func normalizeStringList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + if len(result) == 0 { + return nil + } + return result +} + +var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`) +var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`) + +func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, "empty", errors.New("empty reply") + } + parts := strings.Fields(text) + if len(parts) < 2 { + if len(parts) == 1 && amountPattern.MatchString(parts[0]) { + return nil, "missing_currency", errors.New("currency is required") + } + return nil, "missing_amount", errors.New("amount is required") + } + if len(parts) > 2 { + return nil, "format", errors.New("reply format is invalid") + } + amount := parts[0] + currency := parts[1] + if !amountPattern.MatchString(amount) { + return nil, "invalid_amount", errors.New("amount format is invalid") + } + if !currencyPattern.MatchString(currency) { + return nil, "invalid_currency", errors.New("currency format is invalid") + } + return &paymenttypes.Money{ + Amount: amount, + Currency: strings.ToUpper(currency), + }, "", nil +} + +func confirmationPrompt(req *model.ConfirmationRequest) string { + var builder strings.Builder + builder.WriteString("Payment confirmation required\n") + if req.PaymentIntentID != "" { + builder.WriteString("Payment intent: ") + builder.WriteString(req.PaymentIntentID) + builder.WriteString("\n") + } + if req.QuoteRef != "" { + builder.WriteString("Quote ref: ") + builder.WriteString(req.QuoteRef) + builder.WriteString("\n") + } + if req.RequestedMoney != nil { + builder.WriteString("Requested: ") + builder.WriteString(req.RequestedMoney.Amount) + builder.WriteString(" ") + builder.WriteString(req.RequestedMoney.Currency) + builder.WriteString("\n") + } + builder.WriteString("Reply with \" \" (e.g., 12.34 USD).") + return builder.String() +} + +func clarificationMessage(reason string) string { + switch reason { + case "missing_currency": + return "Currency code is required. Reply with \" \" (e.g., 12.34 USD)." + case "missing_amount": + return "Amount is required. Reply with \" \" (e.g., 12.34 USD)." + case "invalid_amount": + return "Amount must be a decimal number. Reply with \" \" (e.g., 12.34 USD)." + case "invalid_currency": + return "Currency must be a code like USD or EUR. Reply with \" \"." + default: + return "Reply with \" \" (e.g., 12.34 USD)." + } +} diff --git a/api/notification/internal/server/notificationimp/notification.go b/api/notification/internal/server/notificationimp/notification.go index 5c84c45..03b09d7 100644 --- a/api/notification/internal/server/notificationimp/notification.go +++ b/api/notification/internal/server/notificationimp/notification.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/domainprovider" "github.com/tech/sendico/pkg/merrors" na "github.com/tech/sendico/pkg/messaging/notifications/account" + confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation" ni "github.com/tech/sendico/pkg/messaging/notifications/invitation" snotifications "github.com/tech/sendico/pkg/messaging/notifications/site" @@ -26,6 +27,7 @@ type NotificationAPI struct { dp domainprovider.DomainProvider tg telegram.Client announcer *discovery.Announcer + confirm *confirmationManager } func (a *NotificationAPI) Name() mservice.Type { @@ -36,6 +38,9 @@ func (a *NotificationAPI) Finish(_ context.Context) error { if a.announcer != nil { a.announcer.Stop() } + if a.confirm != nil { + a.confirm.Stop() + } return nil } @@ -61,6 +66,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { p.logger.Error("Failed to create telegram client", zap.Error(err)) return nil, err } + p.confirm = newConfirmationManager(p.logger, p.tg, a.Register().Producer()) db, err := a.DBFactory().NewAccountDB() if err != nil { @@ -81,6 +87,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { p.logger.Error("Failed to create confirmation code handler", zap.Error(err)) return nil, err } + if err := a.Register().Consumer(confirmations.NewConfirmationRequestProcessor(p.logger, p.onConfirmationRequest)); err != nil { + p.logger.Error("Failed to register confirmation request handler", zap.Error(err)) + return nil, err + } idb, err := a.DBFactory().NewInvitationsDB() if err != nil { @@ -97,6 +107,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { return nil, err } + if router := a.Router(); router != nil { + router.Post("/telegram/webhook", p.handleTelegramWebhook) + } + announce := discovery.Announcement{ Service: "NOTIFICATIONS", Operations: []string{"notify.send"}, @@ -143,3 +157,10 @@ func (a *NotificationAPI) onCallRequest(ctx context.Context, request *model.Call a.logger.Info("Call request sent via Telegram", zap.String("phone", request.Phone)) return nil } + +func (a *NotificationAPI) onConfirmationRequest(ctx context.Context, request *model.ConfirmationRequest) error { + if a.confirm == nil { + return merrors.Internal("confirmation manager is not configured") + } + return a.confirm.HandleRequest(ctx, request) +} diff --git a/api/notification/internal/server/notificationimp/telegram/client.go b/api/notification/internal/server/notificationimp/telegram/client.go index aabc0eb..43a2ef0 100644 --- a/api/notification/internal/server/notificationimp/telegram/client.go +++ b/api/notification/internal/server/notificationimp/telegram/client.go @@ -25,6 +25,7 @@ type Client interface { SendDemoRequest(ctx context.Context, request *model.DemoRequest) error SendContactRequest(ctx context.Context, request *model.ContactRequest) error SendCallRequest(ctx context.Context, request *model.CallRequest) error + SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error) } type client struct { @@ -38,13 +39,14 @@ type client struct { } type sendMessagePayload struct { - ChatID string `json:"chat_id"` - Text string `json:"text"` - ParseMode string `json:"parse_mode,omitempty"` - ThreadID *int64 `json:"message_thread_id,omitempty"` - DisablePreview bool `json:"disable_web_page_preview,omitempty"` - DisableNotify bool `json:"disable_notification,omitempty"` - ProtectContent bool `json:"protect_content,omitempty"` + ChatID string `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode,omitempty"` + ThreadID *int64 `json:"message_thread_id,omitempty"` + ReplyToMessageID *int64 `json:"reply_to_message_id,omitempty"` + DisablePreview bool `json:"disable_web_page_preview,omitempty"` + DisableNotify bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` } func NewClient(logger mlogger.Logger, cfg *notconfig.TelegramConfig) (Client, error) { @@ -106,16 +108,40 @@ func (c *client) SendDemoRequest(ctx context.Context, request *model.DemoRequest return c.sendForm(ctx, newDemoRequestTemplate(request)) } -func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) error { +type sendMessageResponse struct { + OK bool `json:"ok"` + Result *messageResponse `json:"result,omitempty"` + Description string `json:"description,omitempty"` +} + +type messageResponse struct { + MessageID int64 `json:"message_id"` + Date int64 `json:"date"` + Chat messageChat `json:"chat"` + From *messageUser `json:"from,omitempty"` + Text string `json:"text"` + ReplyToMessage *messageResponse `json:"reply_to_message,omitempty"` +} + +type messageChat struct { + ID int64 `json:"id"` +} + +type messageUser struct { + ID int64 `json:"id"` + Username string `json:"username,omitempty"` +} + +func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) (*model.TelegramMessage, error) { body, err := json.Marshal(&payload) if err != nil { c.logger.Warn("Failed to marshal telegram payload", zap.Error(err)) - return err + return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body)) if err != nil { c.logger.Warn("Failed to create telegram request", zap.Error(err)) - return err + return nil, err } req.Header.Set("Content-Type", "application/json") @@ -129,26 +155,41 @@ func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) er if payload.ThreadID != nil { fields = append(fields, zap.Int64("thread_id", *payload.ThreadID)) } + if payload.ReplyToMessageID != nil { + fields = append(fields, zap.Int64("reply_to_message_id", *payload.ReplyToMessageID)) + } c.logger.Debug("Sending Telegram message", fields...) start := time.Now() resp, err := c.httpClient.Do(req) if err != nil { c.logger.Warn("Telegram request failed", zap.Error(err)) - return err + return nil, err } defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10)) if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + var parsed sendMessageResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + c.logger.Warn("Failed to decode telegram response", zap.Error(err)) + return nil, err + } + if !parsed.OK || parsed.Result == nil { + msg := "telegram sendMessage response missing result" + if parsed.Description != "" { + msg = parsed.Description + } + return nil, merrors.Internal(msg) + } c.logger.Debug("Telegram message sent", zap.Int("status_code", resp.StatusCode), zap.Duration("latency", time.Since(start))) - return nil + return toTelegramMessage(parsed.Result), nil } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) c.logger.Warn("Telegram API returned non-success status", zap.Int("status_code", resp.StatusCode), zap.ByteString("response_body", respBody), zap.String("chat_id", c.chatID)) - return merrors.Internal(fmt.Sprintf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody))) + return nil, merrors.Internal(fmt.Sprintf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody))) } func (c *client) endpoint() string { @@ -178,5 +219,51 @@ func (c *client) sendForm(ctx context.Context, template messageTemplate) error { ThreadID: c.threadID, DisablePreview: true, } + _, err := c.sendMessage(ctx, payload) + return err +} + +func (c *client) SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error) { + chatID = strings.TrimSpace(chatID) + if chatID == "" { + chatID = c.chatID + } + if chatID == "" { + return nil, merrors.InvalidArgument("telegram chat id is empty", "chat_id") + } + payload := sendMessagePayload{ + ChatID: chatID, + Text: text, + ParseMode: c.parseMode.String(), + ThreadID: c.threadID, + DisablePreview: true, + } + if replyToMessageID != "" { + val, err := strconv.ParseInt(replyToMessageID, 10, 64) + if err != nil { + return nil, merrors.InvalidArgumentWrap(err, "invalid reply_to_message_id", "reply_to_message_id") + } + payload.ReplyToMessageID = &val + } return c.sendMessage(ctx, payload) } + +func toTelegramMessage(msg *messageResponse) *model.TelegramMessage { + if msg == nil { + return nil + } + result := &model.TelegramMessage{ + ChatID: strconv.FormatInt(msg.Chat.ID, 10), + MessageID: strconv.FormatInt(msg.MessageID, 10), + Text: msg.Text, + SentAt: msg.Date, + } + if msg.From != nil { + result.FromUserID = strconv.FormatInt(msg.From.ID, 10) + result.FromUsername = msg.From.Username + } + if msg.ReplyToMessage != nil { + result.ReplyToMessageID = strconv.FormatInt(msg.ReplyToMessage.MessageID, 10) + } + return result +} diff --git a/api/notification/internal/server/notificationimp/telegram/update.go b/api/notification/internal/server/notificationimp/telegram/update.go new file mode 100644 index 0000000..e187f1f --- /dev/null +++ b/api/notification/internal/server/notificationimp/telegram/update.go @@ -0,0 +1,50 @@ +package telegram + +import ( + "strconv" + + "github.com/tech/sendico/pkg/model" +) + +type Update struct { + UpdateID int64 `json:"update_id"` + Message *Message `json:"message,omitempty"` +} + +type Message struct { + MessageID int64 `json:"message_id"` + Date int64 `json:"date,omitempty"` + Chat Chat `json:"chat"` + From *User `json:"from,omitempty"` + Text string `json:"text,omitempty"` + ReplyToMessage *Message `json:"reply_to_message,omitempty"` +} + +type Chat struct { + ID int64 `json:"id"` +} + +type User struct { + ID int64 `json:"id"` + Username string `json:"username,omitempty"` +} + +func (m *Message) ToModel() *model.TelegramMessage { + if m == nil { + return nil + } + result := &model.TelegramMessage{ + ChatID: strconv.FormatInt(m.Chat.ID, 10), + MessageID: strconv.FormatInt(m.MessageID, 10), + Text: m.Text, + SentAt: m.Date, + } + if m.From != nil { + result.FromUserID = strconv.FormatInt(m.From.ID, 10) + result.FromUsername = m.From.Username + } + if m.ReplyToMessage != nil { + result.ReplyToMessageID = strconv.FormatInt(m.ReplyToMessage.MessageID, 10) + } + return result +} diff --git a/api/notification/internal/server/notificationimp/webhook.go b/api/notification/internal/server/notificationimp/webhook.go new file mode 100644 index 0000000..1c26575 --- /dev/null +++ b/api/notification/internal/server/notificationimp/webhook.go @@ -0,0 +1,30 @@ +package notificationimp + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/tech/sendico/notification/internal/server/notificationimp/telegram" + "go.uber.org/zap" +) + +const telegramWebhookMaxBody = 1 << 20 + +func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.Request) { + if a == nil || a.confirm == nil { + w.WriteHeader(http.StatusNoContent) + return + } + var update telegram.Update + dec := json.NewDecoder(io.LimitReader(r.Body, telegramWebhookMaxBody)) + if err := dec.Decode(&update); err != nil { + if a.logger != nil { + a.logger.Warn("Failed to decode telegram webhook update", zap.Error(err)) + } + w.WriteHeader(http.StatusBadRequest) + return + } + a.confirm.HandleUpdate(r.Context(), &update) + w.WriteHeader(http.StatusOK) +} diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 81f3f3b..1bf7888 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -6,8 +6,10 @@ import ( mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo" "github.com/tech/sendico/pkg/db" msg "github.com/tech/sendico/pkg/messaging" + mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" ) func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { @@ -20,6 +22,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { func (i *Imp) Shutdown() { i.stopDiscovery() + if i.service != nil { + i.service.Shutdown() + } i.shutdownApp() i.closeClients() } @@ -37,11 +42,24 @@ func (i *Imp) Start() error { return mongostorage.New(logger, conn) } + var broker mb.Broker + if cfg.Messaging != nil && cfg.Messaging.Driver != "" { + broker, err = msg.CreateMessagingBroker(i.logger, cfg.Messaging) + if err != nil { + i.logger.Warn("Failed to create messaging broker", zap.Error(err)) + } + } + deps := i.initDependencies(cfg) serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { opts := i.buildServiceOptions(cfg, deps) - return orchestrator.NewService(logger, repo, opts...), nil + if broker != nil { + opts = append(opts, orchestrator.WithPaymentGatewayBroker(broker)) + } + svc := orchestrator.NewService(logger, repo, opts...) + i.service = svc + return svc, nil } app, err := grpcapp.NewApp(i.logger, "payments_orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory) diff --git a/api/payments/orchestrator/internal/server/internal/types.go b/api/payments/orchestrator/internal/server/internal/types.go index 10b3a38..612a59b 100644 --- a/api/payments/orchestrator/internal/server/internal/types.go +++ b/api/payments/orchestrator/internal/server/internal/types.go @@ -5,6 +5,7 @@ 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/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/mlogger" @@ -22,6 +23,7 @@ type Imp struct { discoveryWatcher *discovery.RegistryWatcher discoveryReg *discovery.Registry discoveryAnnouncer *discovery.Announcer + service *orchestrator.Service feesConn *grpc.ClientConn ledgerClient ledgerclient.Client gatewayClient chainclient.Client diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index 66bc036..a3ed506 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -22,15 +22,16 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent { return model.PaymentIntent{} } intent := model.PaymentIntent{ - Kind: modelKindFromProto(src.GetKind()), - Source: endpointFromProto(src.GetSource()), - Destination: endpointFromProto(src.GetDestination()), - Amount: moneyFromProto(src.GetAmount()), - RequiresFX: src.GetRequiresFx(), - FeePolicy: feePolicyFromProto(src.GetFeePolicy()), - SettlementMode: settlementModeFromProto(src.GetSettlementMode()), - Attributes: cloneMetadata(src.GetAttributes()), - Customer: customerFromProto(src.GetCustomer()), + Kind: modelKindFromProto(src.GetKind()), + Source: endpointFromProto(src.GetSource()), + Destination: endpointFromProto(src.GetDestination()), + Amount: moneyFromProto(src.GetAmount()), + RequiresFX: src.GetRequiresFx(), + FeePolicy: feePolicyFromProto(src.GetFeePolicy()), + SettlementMode: settlementModeFromProto(src.GetSettlementMode()), + SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()), + Attributes: cloneMetadata(src.GetAttributes()), + Customer: customerFromProto(src.GetCustomer()), } if src.GetFx() != nil { intent.FX = fxIntentFromProto(src.GetFx()) @@ -43,8 +44,9 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin return model.PaymentEndpoint{Type: model.EndpointTypeUnspecified} } result := model.PaymentEndpoint{ - Type: model.EndpointTypeUnspecified, - Metadata: cloneMetadata(src.GetMetadata()), + Type: model.EndpointTypeUnspecified, + InstanceID: strings.TrimSpace(src.GetInstanceId()), + Metadata: cloneMetadata(src.GetMetadata()), } if ledger := src.GetLedger(); ledger != nil { result.Type = model.EndpointTypeLedger @@ -160,15 +162,16 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment { func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent { intent := &orchestratorv1.PaymentIntent{ - Kind: protoKindFromModel(src.Kind), - Source: protoEndpointFromModel(src.Source), - Destination: protoEndpointFromModel(src.Destination), - Amount: protoMoney(src.Amount), - RequiresFx: src.RequiresFX, - FeePolicy: feePolicyToProto(src.FeePolicy), - SettlementMode: settlementModeToProto(src.SettlementMode), - Attributes: cloneMetadata(src.Attributes), - Customer: protoCustomerFromModel(src.Customer), + Kind: protoKindFromModel(src.Kind), + Source: protoEndpointFromModel(src.Source), + Destination: protoEndpointFromModel(src.Destination), + Amount: protoMoney(src.Amount), + RequiresFx: src.RequiresFX, + FeePolicy: feePolicyToProto(src.FeePolicy), + SettlementMode: settlementModeToProto(src.SettlementMode), + SettlementCurrency: strings.TrimSpace(src.SettlementCurrency), + Attributes: cloneMetadata(src.Attributes), + Customer: protoCustomerFromModel(src.Customer), } if src.FX != nil { intent.Fx = protoFXIntentFromModel(src.FX) @@ -214,7 +217,8 @@ func protoCustomerFromModel(src *model.Customer) *orchestratorv1.Customer { func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint { endpoint := &orchestratorv1.PaymentEndpoint{ - Metadata: cloneMetadata(src.Metadata), + Metadata: cloneMetadata(src.Metadata), + InstanceId: strings.TrimSpace(src.InstanceID), } switch src.Type { case model.EndpointTypeLedger: @@ -337,11 +341,16 @@ func protoPaymentStepFromModel(src *model.PaymentStep) *orchestratorv1.PaymentSt 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), + Rail: protoRailFromModel(src.Rail), + GatewayId: strings.TrimSpace(src.GatewayID), + Action: protoRailOperationFromModel(src.Action), + Amount: protoMoney(src.Amount), + Ref: strings.TrimSpace(src.Ref), + StepId: strings.TrimSpace(src.StepID), + InstanceId: strings.TrimSpace(src.InstanceID), + DependsOn: cloneStringList(src.DependsOn), + CommitPolicy: strings.TrimSpace(string(src.CommitPolicy)), + CommitAfter: cloneStringList(src.CommitAfter), } } @@ -362,6 +371,8 @@ func protoPaymentPlanFromModel(src *model.PaymentPlan) *orchestratorv1.PaymentPl Id: strings.TrimSpace(src.ID), Steps: steps, IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), + FxQuote: fxQuoteToProto(src.FXQuote), + Fees: feeLinesToProto(src.Fees), } if !src.CreatedAt.IsZero() { plan.CreatedAt = timestamppb.New(src.CreatedAt.UTC()) diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go new file mode 100644 index 0000000..95707b0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go @@ -0,0 +1,101 @@ +package orchestrator + +import ( + "context" + "strings" + + paymodel "github.com/tech/sendico/payments/orchestrator/storage/model" + cons "github.com/tech/sendico/pkg/messaging/consumer" + paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "go.uber.org/zap" +) + +func (s *Service) startGatewayConsumers() { + if s == nil || s.gatewayBroker == nil { + return + } + processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution) + s.consumeGatewayProcessor(processor) +} + +func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) { + consumer, err := cons.NewConsumer(s.logger, s.gatewayBroker, processor.GetSubject()) + if err != nil { + s.logger.Warn("Failed to create payment gateway consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + return + } + s.gatewayConsumers = append(s.gatewayConsumers, consumer) + go func() { + if err := consumer.ConsumeMessages(processor.Process); err != nil { + s.logger.Warn("Payment gateway consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + } + }() +} + +func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error { + if exec == nil { + return merrors.InvalidArgument("payment gateway execution is nil", "execution") + } + paymentRef := strings.TrimSpace(exec.PaymentIntentID) + if paymentRef == "" { + return merrors.InvalidArgument("payment_intent_id is required", "payment_intent_id") + } + if s.storage == nil || s.storage.Payments() == nil { + return errStorageUnavailable + } + payment, err := s.storage.Payments().GetByPaymentRef(ctx, paymentRef) + if err != nil { + return err + } + if payment.Metadata == nil { + payment.Metadata = map[string]string{} + } + if exec.RequestID != "" { + payment.Metadata["gateway_request_id"] = exec.RequestID + } + if exec.QuoteRef != "" { + payment.Metadata["gateway_quote_ref"] = exec.QuoteRef + } + if exec.ExecutedMoney != nil { + payment.Metadata["gateway_executed_amount"] = exec.ExecutedMoney.Amount + payment.Metadata["gateway_executed_currency"] = exec.ExecutedMoney.Currency + } + payment.Metadata["gateway_confirmation_status"] = string(exec.Status) + + switch exec.Status { + case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified: + payment.State = paymodel.PaymentStateSettled + payment.FailureCode = paymodel.PaymentFailureCodeUnspecified + payment.FailureReason = "" + case model.ConfirmationStatusRejected: + payment.State = paymodel.PaymentStateFailed + payment.FailureCode = paymodel.PaymentFailureCodePolicy + payment.FailureReason = "gateway_rejected" + case model.ConfirmationStatusTimeout: + payment.State = paymodel.PaymentStateFailed + payment.FailureCode = paymodel.PaymentFailureCodePolicy + payment.FailureReason = "confirmation_timeout" + default: + s.logger.Warn("Unhandled gateway confirmation status", zap.String("status", string(exec.Status)), zap.String("payment_ref", paymentRef)) + } + if err := s.storage.Payments().Update(ctx, payment); err != nil { + return err + } + s.logger.Info("Payment gateway execution applied", zap.String("payment_ref", paymentRef), zap.String("status", string(exec.Status)), zap.String("service", string(mservice.PaymentGateway))) + return nil +} + +func (s *Service) Shutdown() { + if s == nil { + return + } + for _, consumer := range s.gatewayConsumers { + if consumer != nil { + consumer.Close() + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go new file mode 100644 index 0000000..f1eebe5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go @@ -0,0 +1,69 @@ +package orchestrator + +import ( + "context" + "testing" + + paymodel "github.com/tech/sendico/payments/orchestrator/storage/model" + mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" + "github.com/tech/sendico/pkg/model" +) + +func TestGatewayExecutionConfirmedUpdatesPayment(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + store := newHelperPaymentStore() + payment := &paymodel.Payment{PaymentRef: "pi-1", State: paymodel.PaymentStateSubmitted} + if err := store.Create(context.Background(), payment); err != nil { + t.Fatalf("failed to seed payment: %v", err) + } + svc := &Service{ + logger: logger, + storage: stubRepo{payments: store}, + } + exec := &model.PaymentGatewayExecution{ + PaymentIntentID: "pi-1", + Status: model.ConfirmationStatusConfirmed, + RequestID: "req-1", + QuoteRef: "quote-1", + } + if err := svc.onGatewayExecution(context.Background(), exec); err != nil { + t.Fatalf("onGatewayExecution error: %v", err) + } + updated, _ := store.GetByPaymentRef(context.Background(), "pi-1") + if updated.State != paymodel.PaymentStateSettled { + t.Fatalf("expected payment settled, got %s", updated.State) + } + if updated.Metadata["gateway_request_id"] != "req-1" { + t.Fatalf("expected gateway_request_id metadata") + } + if updated.Metadata["gateway_confirmation_status"] != string(model.ConfirmationStatusConfirmed) { + t.Fatalf("expected gateway_confirmation_status metadata") + } +} + +func TestGatewayExecutionRejectedFailsPayment(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + store := newHelperPaymentStore() + payment := &paymodel.Payment{PaymentRef: "pi-2", State: paymodel.PaymentStateSubmitted} + if err := store.Create(context.Background(), payment); err != nil { + t.Fatalf("failed to seed payment: %v", err) + } + svc := &Service{ + logger: logger, + storage: stubRepo{payments: store}, + } + exec := &model.PaymentGatewayExecution{ + PaymentIntentID: "pi-2", + Status: model.ConfirmationStatusRejected, + } + if err := svc.onGatewayExecution(context.Background(), exec); err != nil { + t.Fatalf("onGatewayExecution error: %v", err) + } + updated, _ := store.GetByPaymentRef(context.Background(), "pi-2") + if updated.State != paymodel.PaymentStateFailed { + t.Fatalf("expected payment failed, got %s", updated.State) + } + if updated.FailureReason != "gateway_rejected" { + t.Fatalf("expected failure reason gateway_rejected, got %q", updated.FailureReason) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go index a1941e9..80ab211 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -465,13 +465,14 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat } intentProto := &orchestratorv1.PaymentIntent{ - Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, - Source: req.GetSource(), - Destination: req.GetDestination(), - Amount: amount, - RequiresFx: true, - Fx: fxIntent, - FeePolicy: req.GetFeePolicy(), + Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, + Source: req.GetSource(), + Destination: req.GetDestination(), + Amount: amount, + RequiresFx: true, + Fx: fxIntent, + FeePolicy: req.GetFeePolicy(), + SettlementCurrency: strings.TrimSpace(amount.GetCurrency()), } quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go index 1b51a02..acc23da 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go @@ -55,14 +55,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or } 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) + ensureExecutionPlanForPlan(payment, payment.PaymentPlan) } updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent()) if payment.Execution == nil { @@ -139,7 +132,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or 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 cardPayoutDependenciesConfirmed(payment.PaymentPlan, payment.ExecutionPlan) { if payment.Execution.CardPayoutRef != "" { payment.State = model.PaymentStateSubmitted } else { diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go index df242d6..8f94acc 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/helpers.go @@ -46,6 +46,24 @@ func cloneMetadata(input map[string]string) map[string]string { return clone } +func cloneStringList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.TrimSpace(value) + if clean == "" { + continue + } + result = append(result, clean) + } + if len(result) == 0 { + return nil + } + return result +} + func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine { if len(lines) == 0 { return nil diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go index 26a819a..519b2fd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go @@ -2,12 +2,14 @@ package orchestrator import ( "context" + "strings" "time" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "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" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -73,10 +75,37 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool { if intent == nil { return false } - if intent.GetRequiresFx() { + if fxIntentForQuote(intent) != nil { return true } - return intent.GetFx() != nil && intent.GetFx().GetPair() != nil + return intent.GetRequiresFx() +} + +func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIntent { + if intent == nil { + return nil + } + if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil { + return fx + } + amount := intent.GetAmount() + if amount == nil { + return nil + } + settlementCurrency := strings.TrimSpace(intent.GetSettlementCurrency()) + if settlementCurrency == "" { + return nil + } + if strings.EqualFold(amount.GetCurrency(), settlementCurrency) { + return nil + } + return &orchestratorv1.FXIntent{ + Pair: &fxv1.CurrencyPair{ + Base: strings.TrimSpace(amount.GetCurrency()), + Quote: settlementCurrency, + }, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + } } func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState { diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index fb46a1b..ebebb32 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -13,6 +13,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" "github.com/tech/sendico/pkg/merrors" + mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" ) @@ -153,6 +154,14 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option } } +func WithPaymentGatewayBroker(broker mb.Broker) Option { + return func(s *Service) { + if broker != nil { + s.gatewayBroker = broker + } + } +} + // WithLedgerClient wires the ledger client. func WithLedgerClient(client ledgerclient.Client) Option { return func(s *Service) { diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go index 104e96b..ff82156 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go @@ -39,11 +39,15 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym if routeStore == nil { return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable) } + planTemplates := p.svc.storage.PlanTemplates() + if planTemplates == nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "plan_templates_store_unavailable", errStorageUnavailable) + } builder := p.svc.deps.planBuilder if builder == nil { builder = &defaultPlanBuilder{} } - plan, err := builder.Build(ctx, payment, quote, routeStore, p.svc.deps.gatewayRegistry) + plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry) if err != nil { return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go index 8fe66d6..f363996 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go @@ -22,28 +22,30 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage. 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) + order, _, err := planExecutionOrder(plan) + if err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) + } + + execPlan := ensureExecutionPlanForPlan(payment, plan) + execSteps := executionStepsByCode(execPlan) + planSteps := planStepsByID(plan) asyncSubmitted := false - for idx, step := range plan.Steps { + for _, idx := range order { + step := plan.Steps[idx] 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] + stepID := planStepID(step, idx) + execStep := execSteps[stepID] + if execStep == nil { + execStep = &model.ExecutionStep{Code: stepID} + execSteps[stepID] = execStep + } status := executionStepStatus(execStep) switch status { case executionStepStatusConfirmed, executionStepStatusSkipped: @@ -58,17 +60,21 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage. 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 + ready, blocked, err := stepDependenciesReady(step, execSteps, planSteps, false) + if err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) + } + if blocked { + payment.State = model.PaymentStateFailed + payment.FailureCode = failureCodeForStep(step) return p.persistPayment(ctx, store, payment) } + if !ready { + continue + } async, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, charges, idx) if err != nil { @@ -76,10 +82,6 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage. } if async { asyncSubmitted = true - if isConsumerExecutionStep(execStep) || step.Action == model.RailOperationObserveConfirm { - payment.State = model.PaymentStateSubmitted - 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 index 1b732d8..c814edd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go @@ -121,12 +121,12 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { 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"}}, + {StepID: "crypto_send", Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}}, + {StepID: "crypto_fee", Rail: model.RailCrypto, Action: model.RailOperationFee, DependsOn: []string{"crypto_send"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}}, + {StepID: "crypto_observe", Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm, DependsOn: []string{"crypto_send"}}, + {StepID: "ledger_credit", Rail: model.RailLedger, Action: model.RailOperationCredit, DependsOn: []string{"crypto_observe"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, + {StepID: "card_payout", Rail: model.RailCardPayout, Action: model.RailOperationSend, DependsOn: []string{"ledger_credit"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, + {StepID: "ledger_debit", Rail: model.RailLedger, Action: model.RailOperationDebit, DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, }, }, } @@ -172,8 +172,8 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { 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 debitCalls != 0 || creditCalls != 1 { + t.Fatalf("expected ledger credit after source confirmation, debit=%d credit=%d", debitCalls, creditCalls) } if payoutCalls != 1 { t.Fatalf("expected card payout submitted, got %d", payoutCalls) @@ -181,4 +181,18 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { if payment.Execution == nil || payment.Execution.CardPayoutRef == "" { t.Fatalf("expected card payout ref set") } + + steps := executionStepsByCode(payment.ExecutionPlan) + cardStep := steps["card_payout"] + if cardStep == nil { + t.Fatalf("expected card payout step in execution plan") + } + setExecutionStepStatus(cardStep, executionStepStatusConfirmed) + + if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil { + t.Fatalf("executePaymentPlan finalize error: %v", err) + } + if debitCalls != 1 || creditCalls != 1 { + t.Fatalf("expected ledger debit after payout confirmation, debit=%d credit=%d", debitCalls, creditCalls) + } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go index 3e5c1d3..0565f4a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go @@ -25,41 +25,7 @@ func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) 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 { +func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan { if payment == nil || plan == nil { return nil } @@ -77,7 +43,7 @@ func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan, } steps := make([]*model.ExecutionStep, len(plan.Steps)) for idx, planStep := range plan.Steps { - code := planStepCode(idx) + code := planStepID(planStep, idx) step := existing[code] if step == nil { step = &model.ExecutionStep{Code: code} @@ -86,11 +52,6 @@ func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan, 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) } @@ -119,7 +80,12 @@ func executionPlanComplete(plan *model.ExecutionPlan) bool { return true } -func planStepCode(idx int) string { +func planStepID(step *model.PaymentStep, idx int) string { + if step != nil { + if val := strings.TrimSpace(step.StepID); val != "" { + return val + } + } return fmt.Sprintf("plan_step_%d", idx) } @@ -144,7 +110,11 @@ func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.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))) + stepID := strings.TrimSpace(step.StepID) + if stepID == "" { + stepID = fmt.Sprintf("%d", idx) + } + return fmt.Sprintf("%s:plan:%s:%s:%s", base, stepID, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action))) } func failureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode { diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go new file mode 100644 index 0000000..d092033 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go @@ -0,0 +1,193 @@ +package orchestrator + +import ( + "sort" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func planExecutionOrder(plan *model.PaymentPlan) ([]int, map[string]int, error) { + if plan == nil || len(plan.Steps) == 0 { + return nil, nil, merrors.InvalidArgument("payment plan: steps are required") + } + idToIndex := map[string]int{} + for idx, step := range plan.Steps { + if step == nil { + return nil, nil, merrors.InvalidArgument("payment plan: step is required") + } + id := planStepID(step, idx) + if _, exists := idToIndex[id]; exists { + return nil, nil, merrors.InvalidArgument("payment plan: duplicate step id") + } + idToIndex[id] = idx + } + + indegree := make([]int, len(plan.Steps)) + adj := make([][]int, len(plan.Steps)) + for idx, step := range plan.Steps { + for _, dep := range step.DependsOn { + key := strings.TrimSpace(dep) + if key == "" { + continue + } + depIdx, ok := idToIndex[key] + if !ok { + return nil, nil, merrors.InvalidArgument("payment plan: dependency missing") + } + adj[depIdx] = append(adj[depIdx], idx) + indegree[idx]++ + } + } + + queue := make([]int, 0, len(plan.Steps)) + for idx := range indegree { + if indegree[idx] == 0 { + queue = append(queue, idx) + } + } + sort.Ints(queue) + + order := make([]int, 0, len(plan.Steps)) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + order = append(order, current) + for _, next := range adj[current] { + indegree[next]-- + if indegree[next] == 0 { + queue = append(queue, next) + } + } + sort.Ints(queue) + } + + if len(order) != len(plan.Steps) { + return nil, nil, merrors.InvalidArgument("payment plan: dependency cycle detected") + } + return order, idToIndex, nil +} + +func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep { + result := map[string]*model.ExecutionStep{} + if plan == nil { + return result + } + for _, step := range plan.Steps { + if step == nil { + continue + } + if code := strings.TrimSpace(step.Code); code != "" { + result[code] = step + } + } + return result +} + +func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep { + result := map[string]*model.PaymentStep{} + if plan == nil { + return result + } + for idx, step := range plan.Steps { + if step == nil { + continue + } + id := planStepID(step, idx) + if id == "" { + continue + } + result[id] = step + } + return result +} + +func stepDependenciesReady(step *model.PaymentStep, execSteps map[string]*model.ExecutionStep, planSteps map[string]*model.PaymentStep, requireConfirmed bool) (bool, bool, error) { + if step == nil { + return false, false, merrors.InvalidArgument("payment plan: step is required") + } + for _, dep := range step.DependsOn { + key := strings.TrimSpace(dep) + if key == "" { + continue + } + execStep := execSteps[key] + if execStep == nil { + return false, false, merrors.InvalidArgument("payment plan: dependency missing") + } + depStep := planSteps[key] + needsConfirm := requireConfirmed + if depStep != nil && depStep.Action == model.RailOperationObserveConfirm { + needsConfirm = true + } + status := executionStepStatus(execStep) + switch status { + case executionStepStatusFailed, executionStepStatusCancelled: + return false, true, nil + case executionStepStatusConfirmed, executionStepStatusSkipped: + continue + case executionStepStatusSubmitted: + if needsConfirm { + return false, false, nil + } + continue + default: + return false, false, nil + } + } + + if step.CommitPolicy != model.CommitPolicyAfterSuccess { + return true, false, nil + } + + commitAfter := step.CommitAfter + if len(commitAfter) == 0 { + commitAfter = step.DependsOn + } + for _, dep := range commitAfter { + key := strings.TrimSpace(dep) + if key == "" { + continue + } + execStep := execSteps[key] + if execStep == nil { + return false, false, merrors.InvalidArgument("payment plan: commit dependency missing") + } + status := executionStepStatus(execStep) + switch status { + case executionStepStatusFailed, executionStepStatusCancelled: + return false, true, nil + case executionStepStatusConfirmed, executionStepStatusSkipped: + continue + default: + return false, false, nil + } + } + return true, false, nil +} + +func cardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { + if execPlan == nil { + return false + } + if plan == nil || len(plan.Steps) == 0 { + return sourceStepsConfirmed(execPlan) + } + execSteps := executionStepsByCode(execPlan) + planSteps := planStepsByID(plan) + for _, step := range plan.Steps { + if step == nil { + continue + } + if step.Rail != model.RailCardPayout || step.Action != model.RailOperationSend { + continue + } + ready, blocked, err := stepDependenciesReady(step, execSteps, planSteps, true) + if err != nil || blocked { + return false + } + return ready + } + return false +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go index d256389..e5a1ade 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go @@ -12,6 +12,11 @@ type RouteStore interface { List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) } +// PlanTemplateStore exposes orchestration plan templates for plan construction. +type PlanTemplateStore interface { + List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) +} + // GatewayRegistry exposes gateway instances for capability-based selection. type GatewayRegistry interface { List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) @@ -19,5 +24,5 @@ type GatewayRegistry interface { // 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) + Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, 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 index 2fe9a56..63c5a90 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go @@ -10,13 +10,16 @@ import ( type defaultPlanBuilder struct{} -func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { +func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, 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") } + if templates == nil { + return nil, merrors.InvalidArgument("plan builder: plan templates store is required") + } intent := payment.Intent if intent.Kind == model.PaymentKindFXConversion { @@ -42,10 +45,19 @@ func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment") } - path, err := buildRoutePath(ctx, routes, sourceRail, destRail, sourceNetwork, destNetwork) + network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) if err != nil { return nil, err } - return b.buildPlanFromRoutePath(ctx, payment, quote, path, sourceRail, destRail, sourceNetwork, destNetwork, gateways) + if _, err := selectRoute(ctx, routes, sourceRail, destRail, network); err != nil { + return nil, err + } + + template, err := selectPlanTemplate(ctx, templates, sourceRail, destRail, network) + if err != nil { + return nil, err + } + + return b.buildPlanFromTemplate(ctx, payment, quote, template, 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 index 44f6bcb..1639889 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "strings" "testing" "github.com/tech/sendico/payments/orchestrator/storage/model" @@ -33,7 +34,8 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { Type: model.EndpointTypeCard, Card: &model.CardEndpoint{MaskedPan: "4111"}, }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}, + SettlementCurrency: "USDT", }, LastQuote: &model.PaymentQuoteSnapshot{ ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, @@ -48,9 +50,26 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { 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}, + {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true}, + }, + } + + templates := &stubPlanTemplateStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailCrypto, + ToRail: model.RailCardPayout, + Network: "TRON", + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, + {StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}}, + {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"crypto_observe"}}, + {StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}}, + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}}, + }, + }, }, } @@ -58,18 +77,21 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { items: []*model.GatewayInstanceDescriptor{ { ID: "crypto-tron", + InstanceID: "crypto-tron-1", Rail: model.RailCrypto, Network: "TRON", Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ - CanPayOut: true, - CanSendFee: true, + CanPayOut: true, + CanSendFee: true, + RequiresObserveConfirm: true, }, Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, IsEnabled: true, }, { ID: "settlement", + InstanceID: "settlement-1", Rail: model.RailProviderSettlement, Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ @@ -80,6 +102,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { }, { ID: "card", + InstanceID: "card-1", Rail: model.RailCardPayout, Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ @@ -91,7 +114,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { }, } - plan, err := builder.Build(ctx, payment, quote, routes, registry) + plan, err := builder.Build(ctx, payment, quote, routes, templates, registry) if err != nil { t.Fatalf("expected plan, got error: %v", err) } @@ -102,12 +125,12 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { 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") + assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-1", "USDT", "100") + assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-1", "USDT", "5") + assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-1", "", "") + assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationCredit, "", "", "USDT", "95") + assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-1", "USDT", "95") + assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationDebit, "", "", "USDT", "95") } func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { @@ -135,9 +158,10 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { } routes := &stubRouteStore{} + templates := &stubPlanTemplateStore{} registry := &stubGatewayRegistry{} - plan, err := builder.Build(ctx, payment, &orchestratorv1.PaymentQuote{}, routes, registry) + plan, err := builder.Build(ctx, payment, &orchestratorv1.PaymentQuote{}, routes, templates, registry) if err == nil { t.Fatalf("expected error, got plan: %#v", plan) } @@ -155,6 +179,17 @@ func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilte if route == nil { continue } + if filter != nil { + if filter.FromRail != "" && route.FromRail != filter.FromRail { + continue + } + if filter.ToRail != "" && route.ToRail != filter.ToRail { + continue + } + if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) { + continue + } + } if filter != nil && filter.IsEnabled != nil { if route.IsEnabled != *filter.IsEnabled { continue @@ -165,6 +200,37 @@ func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilte return &model.PaymentRouteList{Items: items}, nil } +type stubPlanTemplateStore struct { + templates []*model.PaymentPlanTemplate +} + +func (s *stubPlanTemplateStore) List(_ context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { + items := make([]*model.PaymentPlanTemplate, 0, len(s.templates)) + for _, tpl := range s.templates { + if tpl == nil { + continue + } + if filter != nil { + if filter.FromRail != "" && tpl.FromRail != filter.FromRail { + continue + } + if filter.ToRail != "" && tpl.ToRail != filter.ToRail { + continue + } + if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) { + continue + } + } + if filter != nil && filter.IsEnabled != nil { + if tpl.IsEnabled != *filter.IsEnabled { + continue + } + } + items = append(items, tpl) + } + return &model.PaymentPlanTemplateList{Items: items}, nil +} + type stubGatewayRegistry struct { items []*model.GatewayInstanceDescriptor } @@ -173,11 +239,14 @@ func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceD return s.items, nil } -func assertPlanStep(t *testing.T, step *model.PaymentStep, rail model.Rail, action model.RailOperation, gatewayID, currency, amount string) { +func assertPlanStep(t *testing.T, step *model.PaymentStep, stepID string, rail model.Rail, action model.RailOperation, gatewayID, instanceID, currency, amount string) { t.Helper() if step == nil { t.Fatal("expected step") } + if step.StepID != stepID { + t.Fatalf("expected step id %q, got %q", stepID, step.StepID) + } if step.Rail != rail { t.Fatalf("expected rail %s, got %s", rail, step.Rail) } @@ -187,6 +256,9 @@ func assertPlanStep(t *testing.T, step *model.PaymentStep, rail model.Rail, acti if step.GatewayID != gatewayID { t.Fatalf("expected gateway %q, got %q", gatewayID, step.GatewayID) } + if step.InstanceID != instanceID { + t.Fatalf("expected instance %q, got %q", instanceID, step.InstanceID) + } if currency == "" && amount == "" { if step.Amount != nil && step.Amount.Amount != "" { t.Fatalf("expected empty amount, got %v", step.Amount) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go index 3954cb0..a126820 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go @@ -11,17 +11,19 @@ import ( 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) { +func ensureGatewayForAction(ctx context.Context, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, 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 + if instanceID == "" || strings.EqualFold(gw.InstanceID, instanceID) { + if err := validateGatewayAction(gw, network, amount, action, dir); err != nil { + return nil, err + } + return gw, nil } - return gw, nil } - gw, err := selectGateway(ctx, registry, rail, network, amount, action, dir) + gw, err := selectGateway(ctx, registry, rail, network, amount, action, instanceID, dir) if err != nil { return nil, err } @@ -66,7 +68,7 @@ func sendDirectionForRail(rail model.Rail) sendDirection { } } -func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { +func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { if registry == nil { return nil, merrors.InvalidArgument("plan builder: gateway registry is required") } @@ -91,6 +93,9 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai eligible := make([]*model.GatewayInstanceDescriptor, 0) for _, gw := range all { + if instanceID != "" && !strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) { + continue + } if !isGatewayEligible(gw, rail, network, currency, action, dir, amt) { continue } diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go index e23ad4e..96d88dd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go @@ -14,9 +14,11 @@ func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) { return nil, merrors.InvalidArgument("plan builder: payment is required") } step := &model.PaymentStep{ - Rail: model.RailLedger, - Action: model.RailOperationFXConvert, - Amount: cloneMoney(payment.Intent.Amount), + StepID: "fx_convert", + Rail: model.RailLedger, + Action: model.RailOperationFXConvert, + CommitPolicy: model.CommitPolicyImmediate, + Amount: cloneMoney(payment.Intent.Amount), } return &model.PaymentPlan{ ID: payment.PaymentRef, @@ -33,14 +35,20 @@ func buildLedgerTransferPlan(payment *model.Payment) (*model.PaymentPlan, error) amount := cloneMoney(payment.Intent.Amount) steps := []*model.PaymentStep{ { - Rail: model.RailLedger, - Action: model.RailOperationDebit, - Amount: cloneMoney(amount), + StepID: "ledger_debit", + Rail: model.RailLedger, + Action: model.RailOperationDebit, + CommitPolicy: model.CommitPolicyImmediate, + Amount: cloneMoney(amount), }, { - Rail: model.RailLedger, - Action: model.RailOperationCredit, - Amount: cloneMoney(amount), + StepID: "ledger_credit", + Rail: model.RailLedger, + Action: model.RailOperationCredit, + DependsOn: []string{"ledger_debit"}, + CommitPolicy: model.CommitPolicyAfterSuccess, + CommitAfter: []string{"ledger_debit"}, + Amount: cloneMoney(amount), }, } return &model.PaymentPlan{ diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go index 8c78890..51abc30 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go @@ -9,115 +9,85 @@ import ( "github.com/tech/sendico/pkg/merrors" ) -func buildRoutePath(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) ([]*model.PaymentRoute, error) { +func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { + src := strings.ToUpper(strings.TrimSpace(sourceNetwork)) + dst := strings.ToUpper(strings.TrimSpace(destNetwork)) + if src != "" && dst != "" && !strings.EqualFold(src, dst) { + return "", merrors.InvalidArgument("plan builder: source and destination networks mismatch") + } + + override := strings.ToUpper(strings.TrimSpace(attributeLookup(attrs, + "network", + "route_network", + "routeNetwork", + "source_network", + "sourceNetwork", + "destination_network", + "destinationNetwork", + ))) + if override != "" { + if src != "" && !strings.EqualFold(src, override) { + return "", merrors.InvalidArgument("plan builder: source network does not match override") + } + if dst != "" && !strings.EqualFold(dst, override) { + return "", merrors.InvalidArgument("plan builder: destination network does not match override") + } + return override, nil + } + if src != "" { + return src, nil + } + if dst != "" { + return dst, nil + } + return "", nil +} + +func selectRoute(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, network 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}) + result, err := routes.List(ctx, &model.PaymentRouteFilter{ + FromRail: sourceRail, + ToRail: destRail, + Network: "", + 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 { + candidates := make([]*model.PaymentRoute, 0, len(result.Items)) + for _, route := range result.Items { if route == nil || !route.IsEnabled { continue } - from := route.FromRail - to := route.ToRail - if from == "" || to == "" || from == model.RailUnspecified || to == model.RailUnspecified { + if route.FromRail != sourceRail || route.ToRail != destRail { 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 !routeMatchesNetwork(route, network) { + continue } + candidates = append(candidates, route) } - - if !found { + if len(candidates) == 0 { 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") + sort.Slice(candidates, func(i, j int) bool { + pi := routePriority(candidates[i], network) + pj := routePriority(candidates[j], network) + if pi != pj { + return pi < pj } - 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 "" + if candidates[i].Network != candidates[j].Network { + return candidates[i].Network < candidates[j].Network + } + return candidates[i].ID.Hex() < candidates[j].ID.Hex() + }) + return candidates[0], nil } func routeMatchesNetwork(route *model.PaymentRoute, network string) bool { @@ -125,13 +95,14 @@ func routeMatchesNetwork(route *model.PaymentRoute, network string) bool { return false } routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) - if strings.TrimSpace(network) == "" { - return routeNetwork == "" - } + net := strings.ToUpper(strings.TrimSpace(network)) if routeNetwork == "" { return true } - return strings.EqualFold(routeNetwork, network) + if net == "" { + return false + } + return strings.EqualFold(routeNetwork, net) } func routePriority(route *model.PaymentRoute, network string) int { @@ -139,7 +110,8 @@ func routePriority(route *model.PaymentRoute, network string) int { return 2 } routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) - if network != "" && strings.EqualFold(routeNetwork, network) { + net := strings.ToUpper(strings.TrimSpace(network)) + if net != "" && strings.EqualFold(routeNetwork, net) { return 0 } if routeNetwork == "" { diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go index 0caa863..62ebe57 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go @@ -10,9 +10,9 @@ import ( 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") +func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) { + if template == nil { + return nil, merrors.InvalidArgument("plan builder: plan template is required") } sourceAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount") @@ -26,7 +26,7 @@ func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment feeAmount := resolveFeeAmount(payment, quote) feeRequired := isPositiveMoney(feeAmount) - var payoutAmount *paymenttypes.Money + payoutAmount := settlementAmount if destRail == model.RailCardPayout { payoutAmount, err = cardPayoutAmount(payment) if err != nil { @@ -40,192 +40,158 @@ func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment ledgerDebitAmount = payoutAmount } - observeRequired := observeRailsFromPath(path) - intermediate := intermediateRailsFromPath(path, sourceRail, destRail) - - steps := make([]*model.PaymentStep, 0) + steps := make([]*model.PaymentStep, 0, len(template.Steps)) gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{} - observeAdded := map[model.Rail]bool{} - useSourceSend := isSendSourceRail(sourceRail) - useDestSend := isSendDestinationRail(destRail) + stepIDs := map[string]bool{} - for idx, edge := range path { - if edge == nil { + for _, tpl := range template.Steps { + stepID := strings.TrimSpace(tpl.StepID) + if stepID == "" { + return nil, merrors.InvalidArgument("plan builder: plan template step id is required") + } + if stepIDs[stepID] { + return nil, merrors.InvalidArgument("plan builder: plan template step id must be unique") + } + stepIDs[stepID] = true + + action, err := actionForOperation(tpl.Operation) + if err != nil { + return nil, err + } + + amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired) + if err != nil { + return nil, err + } + if amount == nil && action != model.RailOperationObserveConfirm { 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), - }) + policy := tpl.CommitPolicy + if strings.TrimSpace(string(policy)) == "" { + policy = model.CommitPolicyImmediate + } + step := &model.PaymentStep{ + StepID: stepID, + Rail: tpl.Rail, + Action: action, + DependsOn: cloneStringList(tpl.DependsOn), + CommitPolicy: policy, + CommitAfter: cloneStringList(tpl.CommitAfter), + Amount: cloneMoney(amount), } - 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 action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm { + network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork) + instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail) + checkAmount := amount + if action == model.RailOperationObserveConfirm { + checkAmount = observeAmountForRail(tpl.Rail, sourceAmount, settlementAmount, payoutAmount) + } + gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail)) 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 - } + step.GatewayID = strings.TrimSpace(gw.ID) + step.InstanceID = strings.TrimSpace(gw.InstanceID) } - 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 - } - } + steps = append(steps, step) } if len(steps) == 0 { return nil, merrors.InvalidArgument("plan builder: empty payment plan") } + execQuote := executionQuote(payment, quote) return &model.PaymentPlan{ ID: payment.PaymentRef, + FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()), + Fees: feeLinesFromProto(execQuote.GetFeeLines()), 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 +func actionForOperation(operation string) (model.RailOperation, error) { + op := strings.ToLower(strings.TrimSpace(operation)) + switch op { + case "debit", "ledger.debit", "wallet.debit": + return model.RailOperationDebit, nil + case "credit", "ledger.credit", "wallet.credit": + return model.RailOperationCredit, nil + case "fx.convert", "fx_conversion", "fx.converted": + return model.RailOperationFXConvert, nil + case "observe", "observe.confirm", "observe.confirmation", "observe.crypto", "observe.card": + return model.RailOperationObserveConfirm, nil + case "fee", "fee.send": + return model.RailOperationFee, nil + case "send", "payout.card", "payout.crypto", "payout.fiat", "payin.crypto", "payin.fiat", "fund.crypto", "fund.card": + return model.RailOperationSend, nil } - return observe + + switch strings.ToUpper(strings.TrimSpace(operation)) { + case string(model.RailOperationDebit): + return model.RailOperationDebit, nil + case string(model.RailOperationCredit): + return model.RailOperationCredit, nil + case string(model.RailOperationSend): + return model.RailOperationSend, nil + case string(model.RailOperationFee): + return model.RailOperationFee, nil + case string(model.RailOperationObserveConfirm): + return model.RailOperationObserveConfirm, nil + case string(model.RailOperationFXConvert): + return model.RailOperationFXConvert, nil + } + + return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation") } -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 +func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) { + switch action { + case model.RailOperationDebit: + if rail == model.RailLedger { + return cloneMoney(ledgerDebitAmount), nil } - rail := edge.ToRail - if rail == model.RailLedger || rail == sourceRail || rail == destRail || rail == model.RailUnspecified { - continue + return cloneMoney(settlementAmount), nil + case model.RailOperationCredit: + if rail == model.RailLedger { + return cloneMoney(ledgerCreditAmount), nil } - intermediate[rail] = true - } - return intermediate -} - -func isSendSourceRail(rail model.Rail) bool { - switch rail { - case model.RailCrypto, model.RailFiatOnRamp: - return true + return cloneMoney(settlementAmount), nil + case model.RailOperationSend: + switch rail { + case sourceRail: + return cloneMoney(sourceAmount), nil + case destRail: + return cloneMoney(payoutAmount), nil + default: + return cloneMoney(settlementAmount), nil + } + case model.RailOperationFee: + if !feeRequired { + return nil, nil + } + return cloneMoney(feeAmount), nil + case model.RailOperationObserveConfirm: + return nil, nil + case model.RailOperationFXConvert: + return cloneMoney(settlementAmount), nil default: - return false + return nil, merrors.InvalidArgument("plan builder: unsupported action") } } -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 +func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string { + if rail == sourceRail { + return strings.TrimSpace(intent.Source.InstanceID) } - if gw != nil && gw.Capabilities.RequiresObserveConfirm { - return true + if rail == destRail { + return strings.TrimSpace(intent.Destination.InstanceID) } - return false + return "" } func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money { diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go new file mode 100644 index 0000000..799cc0f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go @@ -0,0 +1,128 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func selectPlanTemplate(ctx context.Context, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { + if templates == nil { + return nil, merrors.InvalidArgument("plan builder: plan templates store is required") + } + enabled := true + result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{ + FromRail: sourceRail, + ToRail: destRail, + IsEnabled: &enabled, + }) + if err != nil { + return nil, err + } + if result == nil || len(result.Items) == 0 { + return nil, merrors.InvalidArgument("plan builder: plan template missing") + } + + candidates := make([]*model.PaymentPlanTemplate, 0, len(result.Items)) + for _, tpl := range result.Items { + if tpl == nil || !tpl.IsEnabled { + continue + } + if tpl.FromRail != sourceRail || tpl.ToRail != destRail { + continue + } + if !templateMatchesNetwork(tpl, network) { + continue + } + if err := validatePlanTemplate(tpl); err != nil { + return nil, err + } + candidates = append(candidates, tpl) + } + if len(candidates) == 0 { + return nil, merrors.InvalidArgument("plan builder: plan template missing") + } + + sort.Slice(candidates, func(i, j int) bool { + pi := templatePriority(candidates[i], network) + pj := templatePriority(candidates[j], network) + if pi != pj { + return pi < pj + } + if candidates[i].Network != candidates[j].Network { + return candidates[i].Network < candidates[j].Network + } + return candidates[i].ID.Hex() < candidates[j].ID.Hex() + }) + + return candidates[0], nil +} + +func templateMatchesNetwork(template *model.PaymentPlanTemplate, network string) bool { + if template == nil { + return false + } + templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) + net := strings.ToUpper(strings.TrimSpace(network)) + if templateNetwork == "" { + return true + } + if net == "" { + return false + } + return strings.EqualFold(templateNetwork, net) +} + +func templatePriority(template *model.PaymentPlanTemplate, network string) int { + if template == nil { + return 2 + } + templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) + net := strings.ToUpper(strings.TrimSpace(network)) + if net != "" && strings.EqualFold(templateNetwork, net) { + return 0 + } + if templateNetwork == "" { + return 1 + } + return 2 +} + +func validatePlanTemplate(template *model.PaymentPlanTemplate) error { + if template == nil { + return merrors.InvalidArgument("plan builder: plan template is required") + } + if len(template.Steps) == 0 { + return merrors.InvalidArgument("plan builder: plan template steps are required") + } + seen := map[string]struct{}{} + for _, step := range template.Steps { + id := strings.TrimSpace(step.StepID) + if id == "" { + return merrors.InvalidArgument("plan builder: plan template step id is required") + } + if _, exists := seen[id]; exists { + return merrors.InvalidArgument("plan builder: plan template step id must be unique") + } + seen[id] = struct{}{} + if strings.TrimSpace(step.Operation) == "" { + return merrors.InvalidArgument("plan builder: plan template operation is required") + } + } + for _, step := range template.Steps { + for _, dep := range step.DependsOn { + if _, ok := seen[strings.TrimSpace(dep)]; !ok { + return merrors.InvalidArgument("plan builder: plan template dependency missing") + } + } + for _, dep := range step.CommitAfter { + if _, ok := seen[strings.TrimSpace(dep)]; !ok { + return merrors.InvalidArgument("plan builder: plan template commit dependency missing") + } + } + } + return nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go index 5ca73f7..bdaecb2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go @@ -6,6 +6,7 @@ import ( "time" oracleclient "github.com/tech/sendico/fx/oracle/client" + "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" @@ -21,8 +22,8 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc intent := req.GetIntent() amount := intent.GetAmount() fxSide := fxv1.Side_SIDE_UNSPECIFIED - if intent.GetFx() != nil { - fxSide = intent.GetFx().GetSide() + if fxIntent := fxIntentForQuote(intent); fxIntent != nil { + fxSide = fxIntent.GetSide() } var fxQuote *oraclev1.Quote @@ -42,10 +43,23 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc feeBaseAmount = cloneProtoMoney(amount) } - feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount) + intentModel := intentFromProto(intent) + sourceRail, _, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true) if err != nil { return nil, time.Time{}, err } + destRail, _, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false) + if err != nil { + return nil, time.Time{}, err + } + feeRequired := feesRequiredForRails(sourceRail, destRail) + feeQuote := &feesv1.PrecomputeFeesResponse{} + if feeRequired { + feeQuote, err = s.quoteFees(ctx, orgRef, req, feeBaseAmount) + if err != nil { + return nil, time.Time{}, err + } + } feeCurrency := "" if feeBaseAmount != nil { feeCurrency = feeBaseAmount.GetCurrency() @@ -160,8 +174,11 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches } intent := req.GetIntent() meta := req.GetMeta() - fxIntent := intent.GetFx() + fxIntent := fxIntentForQuote(intent) if fxIntent == nil { + if intent.GetRequiresFx() { + return nil, merrors.InvalidArgument("fx intent missing") + } return nil, nil } @@ -210,6 +227,13 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches return quoteToProto(quote), nil } +func feesRequiredForRails(sourceRail, destRail model.Rail) bool { + if sourceRail == model.RailLedger && destRail == model.RailLedger { + return false + } + return true +} + func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string { if intent == nil || len(s.deps.feeLedgerAccounts) == 0 { return "" diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go new file mode 100644 index 0000000..a8441f1 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go @@ -0,0 +1,148 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + oracleclient "github.com/tech/sendico/fx/oracle/client" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" + "google.golang.org/grpc" +) + +type feeEngineFake struct { + precomputeCalls int +} + +func (f *feeEngineFake) QuoteFees(ctx context.Context, in *feesv1.QuoteFeesRequest, opts ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) { + return &feesv1.QuoteFeesResponse{}, nil +} + +func (f *feeEngineFake) PrecomputeFees(ctx context.Context, in *feesv1.PrecomputeFeesRequest, opts ...grpc.CallOption) (*feesv1.PrecomputeFeesResponse, error) { + f.precomputeCalls++ + return &feesv1.PrecomputeFeesResponse{}, nil +} + +func (f *feeEngineFake) ValidateFeeToken(ctx context.Context, in *feesv1.ValidateFeeTokenRequest, opts ...grpc.CallOption) (*feesv1.ValidateFeeTokenResponse, error) { + return &feesv1.ValidateFeeTokenResponse{}, nil +} + +func TestBuildPaymentQuote_RequestsFXWhenSettlementDiffers(t *testing.T) { + ctx := context.Background() + calls := 0 + + svc := &Service{ + logger: zap.NewNop(), + clock: testClock{now: time.Now()}, + deps: serviceDependencies{ + oracle: oracleDependency{ + client: &oracleclient.Fake{ + GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { + calls++ + return &oracleclient.Quote{ + QuoteRef: "q1", + Pair: params.Pair, + Side: params.Side, + Price: "1.1", + BaseAmount: params.BaseAmount, + QuoteAmount: params.QuoteAmount, + ExpiresAt: time.Now().Add(time.Minute), + }, nil + }, + }, + }, + }, + } + + req := &orchestratorv1.QuotePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, + 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: "100"}, + SettlementCurrency: "EUR", + }, + } + + if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 fx quote call, got %d", calls) + } +} + +func TestBuildPaymentQuote_FeesRequestedForExternalRails(t *testing.T) { + ctx := context.Background() + feeFake := &feeEngineFake{} + + svc := &Service{ + logger: zap.NewNop(), + clock: testClock{now: time.Now()}, + deps: serviceDependencies{ + fees: feesDependency{client: feeFake}, + }, + } + + req := &orchestratorv1.QuotePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, + Intent: &orchestratorv1.PaymentIntent{ + Source: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, + }, + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Card{Card: &orchestratorv1.CardEndpoint{}}, + }, + Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + SettlementCurrency: "USD", + }, + } + + if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if feeFake.precomputeCalls != 1 { + t.Fatalf("expected 1 fee precompute call, got %d", feeFake.precomputeCalls) + } +} + +func TestBuildPaymentQuote_FeesSkippedForLedgerTransfer(t *testing.T) { + ctx := context.Background() + feeFake := &feeEngineFake{} + + svc := &Service{ + logger: zap.NewNop(), + clock: testClock{now: time.Now()}, + deps: serviceDependencies{ + fees: feesDependency{client: feeFake}, + }, + } + + req := &orchestratorv1.QuotePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, + 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: "100"}, + SettlementCurrency: "USD", + }, + } + + if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if feeFake.precomputeCalls != 0 { + t.Fatalf("expected fee precompute to be skipped, got %d", feeFake.precomputeCalls) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go index 397e463..553dfe8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go @@ -42,7 +42,8 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, Intent: &orchestratorv1.PaymentIntent{ - Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + SettlementCurrency: "USD", Fx: &orchestratorv1.FXIntent{ Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"}, Side: fxv1.Side_BUY_BASE_SELL_QUOTE, diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 2cffdd6..f278ab4 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -7,6 +7,8 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers" clockpkg "github.com/tech/sendico/pkg/clock" + msg "github.com/tech/sendico/pkg/messaging" + mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "google.golang.org/grpc" @@ -37,6 +39,9 @@ type Service struct { h handlerSet comp componentSet + gatewayBroker mb.Broker + gatewayConsumers []msg.Consumer + orchestratorv1.UnimplementedPaymentOrchestratorServer } @@ -88,6 +93,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) 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.submitCardPayout, svc.resumePaymentPlan) svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc) + svc.startGatewayConsumers() return svc } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go index b0ead12..994a8d0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go @@ -54,6 +54,9 @@ func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error { if intent.GetAmount() == nil { return merrors.InvalidArgument("intent.amount is required") } + if strings.TrimSpace(intent.GetSettlementCurrency()) == "" { + return merrors.InvalidArgument("intent.settlement_currency is required") + } return 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 0d8f58d..aff6017 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + oracleclient "github.com/tech/sendico/fx/oracle/client" ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" @@ -50,8 +51,9 @@ func TestRequireIdempotencyKey(t *testing.T) { func TestNewPayment(t *testing.T) { org := primitive.NewObjectID() intent := &orchestratorv1.PaymentIntent{ - Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, - SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, + SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + SettlementCurrency: "USD", } quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"} p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote) @@ -79,7 +81,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) { OrgRef: org.Hex(), OrgID: org, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}, + Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"}, QuoteRef: "missing", }) if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" { @@ -89,7 +91,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) { func TestResolvePaymentQuote_Expired(t *testing.T) { org := primitive.NewObjectID() - intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}} + intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} record := &model.PaymentQuoteRecord{ QuoteRef: "q1", Intent: intentFromProto(intent), @@ -114,7 +116,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) { func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { org := primitive.NewObjectID() - intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}} + intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} record := &model.PaymentQuoteRecord{ QuoteRef: "q1", Intent: intentFromProto(intent), @@ -141,6 +143,51 @@ func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { } } +func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) { + org := primitive.NewObjectID() + intent := &orchestratorv1.PaymentIntent{ + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + SettlementCurrency: "USD", + } + record := &model.PaymentQuoteRecord{ + QuoteRef: "q1", + Intent: intentFromProto(intent), + Quote: &model.PaymentQuoteSnapshot{}, + } + + feeFake := &feeEngineFake{} + oracleCalls := 0 + svc := &Service{ + storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, + clock: clockpkg.NewSystem(), + deps: serviceDependencies{ + fees: feesDependency{client: feeFake}, + oracle: oracleDependency{client: &oracleclient.Fake{ + GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { + oracleCalls++ + return &oracleclient.Quote{QuoteRef: "q1", ExpiresAt: time.Now()}, nil + }, + }}, + }, + } + + _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + OrgRef: org.Hex(), + OrgID: org, + Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, + QuoteRef: "q1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if feeFake.precomputeCalls != 0 { + t.Fatalf("expected no fee recompute, got %d", feeFake.precomputeCalls) + } + if oracleCalls != 0 { + t.Fatalf("expected no fx recompute, got %d", oracleCalls) + } +} + func TestInitiatePaymentIdempotency(t *testing.T) { logger := mloggerfactory.NewLogger(false) org := primitive.NewObjectID() @@ -153,9 +200,28 @@ func TestInitiatePaymentIdempotency(t *testing.T) { return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil }, } + routes := &stubRoutesStore{ + routes: []*model.PaymentRoute{ + {FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true}, + }, + } + plans := &stubPlanTemplatesStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailLedger, + ToRail: model.RailLedger, + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit"}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"ledger_debit"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"ledger_debit"}}, + }, + }, + }, + } svc := NewService(logger, stubRepo{ payments: store, - routes: &stubRoutesStore{}, + routes: routes, + plans: plans, }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) svc.ensureHandlers() @@ -166,7 +232,8 @@ func TestInitiatePaymentIdempotency(t *testing.T) { Destination: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + SettlementCurrency: "USD", } req := &orchestratorv1.InitiatePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, @@ -197,7 +264,8 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { Destination: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + SettlementCurrency: "USD", } record := &model.PaymentQuoteRecord{ QuoteRef: "q1", @@ -212,10 +280,29 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil }, } + routes := &stubRoutesStore{ + routes: []*model.PaymentRoute{ + {FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true}, + }, + } + plans := &stubPlanTemplatesStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailLedger, + ToRail: model.RailLedger, + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit"}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"ledger_debit"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"ledger_debit"}}, + }, + }, + }, + } svc := NewService(logger, stubRepo{ payments: store, quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, - routes: &stubRoutesStore{}, + routes: routes, + plans: plans, }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) svc.ensureHandlers() @@ -245,6 +332,7 @@ type stubRepo struct { payments storage.PaymentsStore quotes storage.QuotesStore routes storage.RoutesStore + plans storage.PlanTemplatesStore pingErr error } @@ -252,6 +340,12 @@ 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 } +func (s stubRepo) PlanTemplates() storage.PlanTemplatesStore { + if s.plans != nil { + return s.plans + } + return &stubPlanTemplatesStore{} +} 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 dc7d0da..ead5954 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -94,11 +94,25 @@ func TestExecutePayment_ChainFailure(t *testing.T) { store := newStubPaymentsStore() 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}, + {FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON", IsEnabled: true}, }, } - repo := &stubRepository{store: store, routes: routes} + plans := &stubPlanTemplatesStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailCrypto, + ToRail: model.RailLedger, + Network: "TRON", + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, + {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"crypto_observe"}}, + }, + }, + }, + } + repo := &stubRepository{store: store, routes: routes, plans: plans} svc := &Service{ logger: zap.NewNop(), clock: testClock{now: time.Now()}, @@ -116,20 +130,12 @@ func TestExecutePayment_ChainFailure(t *testing.T) { items: []*model.GatewayInstanceDescriptor{ { ID: "crypto-tron", + InstanceID: "crypto-tron-1", 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{ + CanPayOut: true, RequiresObserveConfirm: true, }, Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, @@ -367,6 +373,7 @@ type stubRepository struct { store *stubPaymentsStore quotes storage.QuotesStore routes storage.RoutesStore + plans storage.PlanTemplatesStore } func (r *stubRepository) Ping(context.Context) error { return nil } @@ -384,6 +391,13 @@ func (r *stubRepository) Routes() storage.RoutesStore { return &stubRoutesStore{} } +func (r *stubRepository) PlanTemplates() storage.PlanTemplatesStore { + if r.plans != nil { + return r.plans + } + return &stubPlanTemplatesStore{} +} + type stubQuotesStore struct { quotes map[string]*model.PaymentQuoteRecord } @@ -431,6 +445,17 @@ func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFi if route == nil { continue } + if filter != nil { + if filter.FromRail != "" && route.FromRail != filter.FromRail { + continue + } + if filter.ToRail != "" && route.ToRail != filter.ToRail { + continue + } + if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) { + continue + } + } if filter != nil && filter.IsEnabled != nil { if route.IsEnabled != *filter.IsEnabled { continue @@ -441,6 +466,49 @@ func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFi return &model.PaymentRouteList{Items: items}, nil } +type stubPlanTemplatesStore struct { + templates []*model.PaymentPlanTemplate +} + +func (s *stubPlanTemplatesStore) Create(ctx context.Context, template *model.PaymentPlanTemplate) error { + return merrors.InvalidArgument("plan templates store not implemented") +} + +func (s *stubPlanTemplatesStore) Update(ctx context.Context, template *model.PaymentPlanTemplate) error { + return merrors.InvalidArgument("plan templates store not implemented") +} + +func (s *stubPlanTemplatesStore) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error) { + return nil, storage.ErrPlanTemplateNotFound +} + +func (s *stubPlanTemplatesStore) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { + items := make([]*model.PaymentPlanTemplate, 0, len(s.templates)) + for _, tpl := range s.templates { + if tpl == nil { + continue + } + if filter != nil { + if filter.FromRail != "" && tpl.FromRail != filter.FromRail { + continue + } + if filter.ToRail != "" && tpl.ToRail != filter.ToRail { + continue + } + if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) { + continue + } + } + if filter != nil && filter.IsEnabled != nil { + if tpl.IsEnabled != *filter.IsEnabled { + continue + } + } + items = append(items, tpl) + } + return &model.PaymentPlanTemplateList{Items: items}, nil +} + type stubPaymentsStore struct { payments map[string]*model.Payment byChain map[string]*model.Payment diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index 88c33e7..3560006 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -29,6 +29,15 @@ const ( SettlementModeFixReceived SettlementMode = "fix_received" ) +// CommitPolicy controls when a step is committed during orchestration. +type CommitPolicy string + +const ( + CommitPolicyUnspecified CommitPolicy = "UNSPECIFIED" + CommitPolicyImmediate CommitPolicy = "IMMEDIATE" + CommitPolicyAfterSuccess CommitPolicy = "AFTER_SUCCESS" +) + // PaymentState enumerates lifecycle phases. type PaymentState string @@ -180,6 +189,7 @@ type CardPayout struct { // PaymentEndpoint is a polymorphic payment destination/source. type PaymentEndpoint struct { Type PaymentEndpointType `bson:"type" json:"type"` + InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"` Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"` ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"` ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"` @@ -199,16 +209,17 @@ type FXIntent struct { // 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 *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"` + 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"` + SettlementCurrency string `bson:"settlementCurrency,omitempty" json:"settlementCurrency,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. @@ -249,19 +260,26 @@ type ExecutionRefs struct { // 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"` + StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"` + Rail Rail `bson:"rail" json:"rail"` + GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` + InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"` + Action RailOperation `bson:"action" json:"action"` + DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` + CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` + CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` + 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"` + ID string `bson:"id,omitempty" json:"id,omitempty"` + FXQuote *paymenttypes.FXQuote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` + Fees []*paymenttypes.FeeLine `bson:"fees,omitempty" json:"fees,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. @@ -338,6 +356,7 @@ func (p *Payment) Normalize() { p.Intent.Attributes[k] = strings.TrimSpace(v) } } + p.Intent.SettlementCurrency = strings.TrimSpace(p.Intent.SettlementCurrency) if p.Intent.Customer != nil { p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID) p.Intent.Customer.FirstName = strings.TrimSpace(p.Intent.Customer.FirstName) @@ -380,9 +399,14 @@ func (p *Payment) Normalize() { if step == nil { continue } + step.StepID = strings.TrimSpace(step.StepID) step.Rail = Rail(strings.TrimSpace(string(step.Rail))) step.GatewayID = strings.TrimSpace(step.GatewayID) + step.InstanceID = strings.TrimSpace(step.InstanceID) step.Action = RailOperation(strings.TrimSpace(string(step.Action))) + step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) + step.DependsOn = normalizeStringList(step.DependsOn) + step.CommitAfter = normalizeStringList(step.CommitAfter) step.Ref = strings.TrimSpace(step.Ref) } } @@ -392,6 +416,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) { if ep == nil { return } + ep.InstanceID = strings.TrimSpace(ep.InstanceID) if ep.Metadata != nil { for k, v := range ep.Metadata { ep.Metadata[k] = strings.TrimSpace(v) @@ -433,3 +458,34 @@ func normalizeEndpoint(ep *PaymentEndpoint) { } } } + +func normalizeCommitPolicy(policy CommitPolicy) CommitPolicy { + val := strings.ToUpper(strings.TrimSpace(string(policy))) + switch CommitPolicy(val) { + case CommitPolicyImmediate, CommitPolicyAfterSuccess: + return CommitPolicy(val) + default: + if val == "" { + return CommitPolicyUnspecified + } + return CommitPolicy(val) + } +} + +func normalizeStringList(items []string) []string { + if len(items) == 0 { + return nil + } + result := make([]string, 0, len(items)) + for _, item := range items { + clean := strings.TrimSpace(item) + if clean == "" { + continue + } + result = append(result, clean) + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/api/payments/orchestrator/storage/model/plan_template.go b/api/payments/orchestrator/storage/model/plan_template.go new file mode 100644 index 0000000..86339f4 --- /dev/null +++ b/api/payments/orchestrator/storage/model/plan_template.go @@ -0,0 +1,69 @@ +package model + +import ( + "strings" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" +) + +// OrchestrationStep defines a template step for execution planning. +type OrchestrationStep struct { + StepID string `bson:"stepId" json:"stepId"` + Rail Rail `bson:"rail" json:"rail"` + Operation string `bson:"operation" json:"operation"` + DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` + CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` + CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` +} + +// PaymentPlanTemplate stores reusable orchestration templates. +type PaymentPlanTemplate 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"` + Steps []OrchestrationStep `bson:"steps,omitempty" json:"steps,omitempty"` + IsEnabled bool `bson:"isEnabled" json:"isEnabled"` +} + +// Collection implements storable.Storable. +func (*PaymentPlanTemplate) Collection() string { + return mservice.PaymentPlanTemplates +} + +// Normalize standardizes template fields for matching and indexing. +func (t *PaymentPlanTemplate) Normalize() { + if t == nil { + return + } + t.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.FromRail)))) + t.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.ToRail)))) + t.Network = strings.ToUpper(strings.TrimSpace(t.Network)) + if len(t.Steps) == 0 { + return + } + for i := range t.Steps { + step := &t.Steps[i] + step.StepID = strings.TrimSpace(step.StepID) + step.Rail = Rail(strings.ToUpper(strings.TrimSpace(string(step.Rail)))) + step.Operation = strings.ToLower(strings.TrimSpace(step.Operation)) + step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) + step.DependsOn = normalizeStringList(step.DependsOn) + step.CommitAfter = normalizeStringList(step.CommitAfter) + } +} + +// PaymentPlanTemplateFilter selects templates for lookup. +type PaymentPlanTemplateFilter struct { + FromRail Rail + ToRail Rail + Network string + IsEnabled *bool +} + +// PaymentPlanTemplateList holds template results. +type PaymentPlanTemplateList struct { + Items []*PaymentPlanTemplate +} diff --git a/api/payments/orchestrator/storage/mongo/repository.go b/api/payments/orchestrator/storage/mongo/repository.go index d8ad407..59e22dc 100644 --- a/api/payments/orchestrator/storage/mongo/repository.go +++ b/api/payments/orchestrator/storage/mongo/repository.go @@ -20,6 +20,7 @@ type Store struct { payments storage.PaymentsStore quotes storage.QuotesStore routes storage.RoutesStore + plans storage.PlanTemplatesStore } // New constructs a Mongo-backed payments repository from a Mongo connection. @@ -30,11 +31,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()) routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection()) - return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo) + plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection()) + return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo, plansRepo) } // 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, routesRepo repository.Repository) (*Store, error) { +func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, plansRepo repository.Repository) (*Store, error) { if ping == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil") } @@ -47,6 +49,9 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, if routesRepo == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil") } + if plansRepo == nil { + return nil, merrors.InvalidArgument("payments.storage.mongo: plan templates repository is nil") + } childLogger := logger.Named("storage").Named("mongo") paymentsStore, err := store.NewPayments(childLogger, paymentsRepo) @@ -61,12 +66,17 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, if err != nil { return nil, err } + plansStore, err := store.NewPlanTemplates(childLogger, plansRepo) + if err != nil { + return nil, err + } result := &Store{ logger: childLogger, ping: ping, payments: paymentsStore, quotes: quotesStore, routes: routesStore, + plans: plansStore, } return result, nil @@ -95,4 +105,9 @@ func (s *Store) Routes() storage.RoutesStore { return s.routes } +// PlanTemplates returns the plan templates store. +func (s *Store) PlanTemplates() storage.PlanTemplatesStore { + return s.plans +} + var _ storage.Repository = (*Store)(nil) diff --git a/api/payments/orchestrator/storage/mongo/store/plan_templates.go b/api/payments/orchestrator/storage/mongo/store/plan_templates.go new file mode 100644 index 0000000..84cc028 --- /dev/null +++ b/api/payments/orchestrator/storage/mongo/store/plan_templates.go @@ -0,0 +1,168 @@ +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 PlanTemplates struct { + logger mlogger.Logger + repo repository.Repository +} + +// NewPlanTemplates constructs a Mongo-backed plan template store. +func NewPlanTemplates(logger mlogger.Logger, repo repository.Repository) (*PlanTemplates, error) { + if repo == nil { + return nil, merrors.InvalidArgument("planTemplatesStore: 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 plan templates index", zap.Error(err), zap.String("collection", repo.Collection())) + return nil, err + } + } + + return &PlanTemplates{ + logger: logger.Named("plan_templates"), + repo: repo, + }, nil +} + +func (p *PlanTemplates) Create(ctx context.Context, template *model.PaymentPlanTemplate) error { + if template == nil { + return merrors.InvalidArgument("planTemplatesStore: nil template") + } + template.Normalize() + if template.FromRail == "" || template.FromRail == model.RailUnspecified { + return merrors.InvalidArgument("planTemplatesStore: from_rail is required") + } + if template.ToRail == "" || template.ToRail == model.RailUnspecified { + return merrors.InvalidArgument("planTemplatesStore: to_rail is required") + } + if len(template.Steps) == 0 { + return merrors.InvalidArgument("planTemplatesStore: steps are required") + } + if template.ID.IsZero() { + template.SetID(primitive.NewObjectID()) + } else { + template.Update() + } + + filter := repository.Filter("fromRail", template.FromRail).And( + repository.Filter("toRail", template.ToRail), + repository.Filter("network", template.Network), + ) + + if err := p.repo.Insert(ctx, template, filter); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + return storage.ErrDuplicatePlanTemplate + } + return err + } + return nil +} + +func (p *PlanTemplates) Update(ctx context.Context, template *model.PaymentPlanTemplate) error { + if template == nil { + return merrors.InvalidArgument("planTemplatesStore: nil template") + } + if template.ID.IsZero() { + return merrors.InvalidArgument("planTemplatesStore: missing template id") + } + template.Normalize() + template.Update() + if err := p.repo.Update(ctx, template); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return storage.ErrPlanTemplateNotFound + } + return err + } + return nil +} + +func (p *PlanTemplates) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error) { + if id == primitive.NilObjectID { + return nil, merrors.InvalidArgument("planTemplatesStore: template id is required") + } + entity := &model.PaymentPlanTemplate{} + if err := p.repo.Get(ctx, id, entity); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return nil, storage.ErrPlanTemplateNotFound + } + return nil, err + } + return entity, nil +} + +func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { + if filter == nil { + filter = &model.PaymentPlanTemplateFilter{} + } + + 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) + } + + templates := make([]*model.PaymentPlanTemplate, 0) + decoder := func(cur *mongo.Cursor) error { + item := &model.PaymentPlanTemplate{} + if err := cur.Decode(item); err != nil { + return err + } + templates = append(templates, item) + return nil + } + + if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) { + return nil, err + } + + return &model.PaymentPlanTemplateList{ + Items: templates, + }, nil +} + +var _ storage.PlanTemplatesStore = (*PlanTemplates)(nil) diff --git a/api/payments/orchestrator/storage/storage.go b/api/payments/orchestrator/storage/storage.go index 2f4e524..f85c92f 100644 --- a/api/payments/orchestrator/storage/storage.go +++ b/api/payments/orchestrator/storage/storage.go @@ -26,6 +26,10 @@ var ( 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") + // ErrPlanTemplateNotFound signals that a plan template record does not exist. + ErrPlanTemplateNotFound = storageError("payments.orchestrator.storage: plan template not found") + // ErrDuplicatePlanTemplate signals that a plan template already exists for the same transition. + ErrDuplicatePlanTemplate = storageError("payments.orchestrator.storage: duplicate plan template") ) // Repository exposes persistence primitives for the orchestrator domain. @@ -34,6 +38,7 @@ type Repository interface { Payments() PaymentsStore Quotes() QuotesStore Routes() RoutesStore + PlanTemplates() PlanTemplatesStore } // PaymentsStore manages payment lifecycle state. @@ -59,3 +64,11 @@ type RoutesStore interface { GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) } + +// PlanTemplatesStore manages orchestration plan templates. +type PlanTemplatesStore interface { + Create(ctx context.Context, template *model.PaymentPlanTemplate) error + Update(ctx context.Context, template *model.PaymentPlanTemplate) error + GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error) + List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) +} diff --git a/api/pkg/discovery/service.go b/api/pkg/discovery/service.go index ea28190..faf1796 100644 --- a/api/pkg/discovery/service.go +++ b/api/pkg/discovery/service.go @@ -93,7 +93,13 @@ func (s *RegistryService) Start() { return } s.startOnce.Do(func() { - s.logInfo("Discovery registry service starting", zap.Int("consumers", len(s.consumers)), zap.Bool("kv_enabled", s.kv != nil)) + fields := []zap.Field{zap.Int("consumers", len(s.consumers)), zap.Bool("kv_enabled", s.kv != nil)} + if s.kv != nil { + if bucket := s.kv.Bucket(); bucket != "" { + fields = append(fields, zap.String("kv_bucket", bucket)) + } + } + s.logInfo("Discovery registry service starting", fields...) for _, ch := range s.consumers { ch := ch go func() { @@ -130,6 +136,12 @@ func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) err s.logWarn("Failed to decode discovery announce payload", fields...) return err } + s.logDebug("Discovery announce received", append(envelopeFields(env), announcementFields(payload)...)...) + if strings.TrimSpace(payload.InstanceID) == "" && strings.TrimSpace(payload.ID) == "" { + fields := append(envelopeFields(env), announcementFields(payload)...) + s.logWarn("Discovery announce missing id and instance id", fields...) + return nil + } if strings.TrimSpace(payload.InstanceID) == "" { fields := append(envelopeFields(env), announcementFields(payload)...) s.logWarn("Discovery announce missing instance id", fields...) @@ -151,6 +163,7 @@ func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) er s.logWarn("Failed to decode discovery heartbeat payload", fields...) return err } + s.logDebug("Discovery heartbeat received", append(envelopeFields(env), zap.String("id", payload.ID), zap.String("instance_id", payload.InstanceID), zap.String("status", payload.Status))...) if strings.TrimSpace(payload.InstanceID) == "" && strings.TrimSpace(payload.ID) == "" { return nil } @@ -163,6 +176,10 @@ func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) er ts = time.Now() } results := s.registry.UpdateHeartbeat(payload.ID, payload.InstanceID, strings.TrimSpace(payload.Status), ts, time.Now()) + if len(results) == 0 { + s.logDebug("Discovery heartbeat ignored: entry not found", zap.String("id", payload.ID), zap.String("instance_id", payload.InstanceID)) + return nil + } for _, result := range results { if result.BecameHealthy { s.logInfo("Discovery registry entry became healthy", append(entryFields(result.Entry), zap.String("status", result.Entry.Status))...) @@ -186,6 +203,7 @@ func (s *RegistryService) handleLookup(_ context.Context, env me.Envelope) error } resp := s.registry.Lookup(time.Now()) resp.RequestID = strings.TrimSpace(payload.RequestID) + s.logDebug("Discovery lookup prepared", zap.String("request_id", resp.RequestID), zap.Int("services", len(resp.Services)), zap.Int("gateways", len(resp.Gateways))) if err := s.producer.SendMessage(NewLookupResponseEnvelope(s.sender, resp)); err != nil { fields := []zap.Field{zap.String("request_id", resp.RequestID), zap.Error(err)} s.logWarn("Failed to publish discovery lookup response", fields...) @@ -221,10 +239,12 @@ func (s *RegistryService) initKV(msgBroker mb.Broker) { } provider, ok := msgBroker.(jetStreamProvider) if !ok { + s.logInfo("Discovery KV disabled: broker does not support JetStream") return } js := provider.JetStream() if js == nil { + s.logWarn("Discovery KV disabled: JetStream not configured") return } store, err := NewKVStore(s.logger, js, "") @@ -255,10 +275,25 @@ func (s *RegistryService) consumeKVUpdates(watcher nats.KeyWatcher) { if s == nil || watcher == nil { return } + initial := true + initialCount := 0 for entry := range watcher.Updates() { if entry == nil { + if initial { + fields := []zap.Field{zap.Int("entries", initialCount)} + if s.kv != nil { + if bucket := s.kv.Bucket(); bucket != "" { + fields = append(fields, zap.String("bucket", bucket)) + } + } + s.logInfo("Discovery KV initial sync complete", fields...) + initial = false + } continue } + if initial && entry.Operation() == nats.KeyValuePut { + initialCount++ + } switch entry.Operation() { case nats.KeyValueDelete, nats.KeyValuePurge: key := registryKeyFromKVKey(entry.Key()) @@ -302,6 +337,13 @@ func (s *RegistryService) logWarn(message string, fields ...zap.Field) { s.logger.Warn(message, fields...) } +func (s *RegistryService) logDebug(message string, fields ...zap.Field) { + if s.logger == nil { + return + } + s.logger.Debug(message, fields...) +} + func (s *RegistryService) logInfo(message string, fields ...zap.Field) { if s.logger == nil { return diff --git a/api/pkg/discovery/watcher.go b/api/pkg/discovery/watcher.go index 559196b..9a80b54 100644 --- a/api/pkg/discovery/watcher.go +++ b/api/pkg/discovery/watcher.go @@ -92,10 +92,23 @@ func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) { if w == nil || watcher == nil { return } + initial := true + initialCount := 0 for entry := range watcher.Updates() { if entry == nil { + if initial && w.logger != nil { + fields := []zap.Field{zap.Int("entries", initialCount)} + if w.kv != nil { + fields = append(fields, zap.String("bucket", w.kv.Bucket())) + } + w.logger.Info("Discovery registry watcher initial sync complete", fields...) + initial = false + } continue } + if initial && entry.Operation() == nats.KeyValuePut { + initialCount++ + } switch entry.Operation() { case nats.KeyValueDelete, nats.KeyValuePurge: key := registryKeyFromKVKey(entry.Key()) diff --git a/api/pkg/messaging/internal/notifications/confirmations/notification.go b/api/pkg/messaging/internal/notifications/confirmations/notification.go new file mode 100644 index 0000000..1b381e8 --- /dev/null +++ b/api/pkg/messaging/internal/notifications/confirmations/notification.go @@ -0,0 +1,77 @@ +package notifications + +import ( + "encoding/json" + "strings" + + 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" +) + +type ConfirmationRequestNotification struct { + messaging.Envelope + payload model.ConfirmationRequest +} + +func (crn *ConfirmationRequestNotification) Serialize() ([]byte, error) { + data, err := json.Marshal(crn.payload) + if err != nil { + return nil, err + } + return crn.Envelope.Wrap(data) +} + +type ConfirmationResultNotification struct { + messaging.Envelope + payload model.ConfirmationResult +} + +func (crn *ConfirmationResultNotification) Serialize() ([]byte, error) { + data, err := json.Marshal(crn.payload) + if err != nil { + return nil, err + } + return crn.Envelope.Wrap(data) +} + +func confirmationRequestEvent() model.NotificationEvent { + return model.NewNotification(mservice.Notifications, nm.NAConfirmationRequest) +} + +func confirmationResultEvent(sourceService, rail string) model.NotificationEvent { + action := strings.TrimSpace(sourceService) + if action == "" { + action = "unknown" + } + action = strings.ToLower(action) + rail = strings.TrimSpace(rail) + if rail == "" { + rail = "default" + } + rail = strings.ToLower(rail) + return model.NewNotification(mservice.Confirmations, nm.NotificationAction(action+"."+rail)) +} + +func NewConfirmationRequestEnvelope(sender string, request *model.ConfirmationRequest) messaging.Envelope { + var payload model.ConfirmationRequest + if request != nil { + payload = *request + } + return &ConfirmationRequestNotification{ + Envelope: messaging.CreateEnvelope(sender, confirmationRequestEvent()), + payload: payload, + } +} + +func NewConfirmationResultEnvelope(sender string, result *model.ConfirmationResult, sourceService, rail string) messaging.Envelope { + var payload model.ConfirmationResult + if result != nil { + payload = *result + } + return &ConfirmationResultNotification{ + Envelope: messaging.CreateEnvelope(sender, confirmationResultEvent(sourceService, rail)), + payload: payload, + } +} diff --git a/api/pkg/messaging/internal/notifications/confirmations/processor.go b/api/pkg/messaging/internal/notifications/confirmations/processor.go new file mode 100644 index 0000000..7110906 --- /dev/null +++ b/api/pkg/messaging/internal/notifications/confirmations/processor.go @@ -0,0 +1,81 @@ +package notifications + +import ( + "context" + "encoding/json" + + me "github.com/tech/sendico/pkg/messaging/envelope" + ch "github.com/tech/sendico/pkg/messaging/notifications/confirmations/handler" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "go.uber.org/zap" +) + +type ConfirmationRequestProcessor struct { + logger mlogger.Logger + handler ch.ConfirmationRequestHandler + event model.NotificationEvent +} + +func (crp *ConfirmationRequestProcessor) Process(ctx context.Context, envelope me.Envelope) error { + var msg model.ConfirmationRequest + if err := json.Unmarshal(envelope.GetData(), &msg); err != nil { + crp.logger.Warn("Failed to decode confirmation request envelope", zap.Error(err), zap.String("topic", crp.event.ToString())) + return err + } + if crp.handler == nil { + crp.logger.Warn("Confirmation request handler is not configured", zap.String("topic", crp.event.ToString())) + return nil + } + return crp.handler(ctx, &msg) +} + +func (crp *ConfirmationRequestProcessor) GetSubject() model.NotificationEvent { + return crp.event +} + +type ConfirmationResultProcessor struct { + logger mlogger.Logger + handler ch.ConfirmationResultHandler + event model.NotificationEvent +} + +func (crp *ConfirmationResultProcessor) Process(ctx context.Context, envelope me.Envelope) error { + var msg model.ConfirmationResult + if err := json.Unmarshal(envelope.GetData(), &msg); err != nil { + crp.logger.Warn("Failed to decode confirmation result envelope", zap.Error(err), zap.String("topic", crp.event.ToString())) + return err + } + if crp.handler == nil { + crp.logger.Warn("Confirmation result handler is not configured", zap.String("topic", crp.event.ToString())) + return nil + } + return crp.handler(ctx, &msg) +} + +func (crp *ConfirmationResultProcessor) GetSubject() model.NotificationEvent { + return crp.event +} + +func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor { + if logger != nil { + logger = logger.Named("confirmation_request_processor") + } + return &ConfirmationRequestProcessor{ + logger: logger, + handler: handler, + event: confirmationRequestEvent(), + } +} + +func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationResultHandler) np.EnvelopeProcessor { + if logger != nil { + logger = logger.Named("confirmation_result_processor") + } + return &ConfirmationResultProcessor{ + logger: logger, + handler: handler, + event: confirmationResultEvent(sourceService, rail), + } +} diff --git a/api/pkg/messaging/internal/notifications/paymentgateway/notification.go b/api/pkg/messaging/internal/notifications/paymentgateway/notification.go new file mode 100644 index 0000000..2b7ea8e --- /dev/null +++ b/api/pkg/messaging/internal/notifications/paymentgateway/notification.go @@ -0,0 +1,66 @@ +package notifications + +import ( + "encoding/json" + + 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" +) + +type PaymentGatewayIntentNotification struct { + messaging.Envelope + payload model.PaymentGatewayIntent +} + +func (pgn *PaymentGatewayIntentNotification) Serialize() ([]byte, error) { + data, err := json.Marshal(pgn.payload) + if err != nil { + return nil, err + } + return pgn.Envelope.Wrap(data) +} + +type PaymentGatewayExecutionNotification struct { + messaging.Envelope + payload model.PaymentGatewayExecution +} + +func (pgn *PaymentGatewayExecutionNotification) Serialize() ([]byte, error) { + data, err := json.Marshal(pgn.payload) + if err != nil { + return nil, err + } + return pgn.Envelope.Wrap(data) +} + +func intentEvent() model.NotificationEvent { + return model.NewNotification(mservice.PaymentGateway, nm.NAPaymentGatewayIntent) +} + +func executionEvent() model.NotificationEvent { + return model.NewNotification(mservice.PaymentGateway, nm.NAPaymentGatewayExecution) +} + +func NewPaymentGatewayIntentEnvelope(sender string, intent *model.PaymentGatewayIntent) messaging.Envelope { + var payload model.PaymentGatewayIntent + if intent != nil { + payload = *intent + } + return &PaymentGatewayIntentNotification{ + Envelope: messaging.CreateEnvelope(sender, intentEvent()), + payload: payload, + } +} + +func NewPaymentGatewayExecutionEnvelope(sender string, exec *model.PaymentGatewayExecution) messaging.Envelope { + var payload model.PaymentGatewayExecution + if exec != nil { + payload = *exec + } + return &PaymentGatewayExecutionNotification{ + Envelope: messaging.CreateEnvelope(sender, executionEvent()), + payload: payload, + } +} diff --git a/api/pkg/messaging/internal/notifications/paymentgateway/processor.go b/api/pkg/messaging/internal/notifications/paymentgateway/processor.go new file mode 100644 index 0000000..f957534 --- /dev/null +++ b/api/pkg/messaging/internal/notifications/paymentgateway/processor.go @@ -0,0 +1,81 @@ +package notifications + +import ( + "context" + "encoding/json" + + me "github.com/tech/sendico/pkg/messaging/envelope" + ch "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway/handler" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "go.uber.org/zap" +) + +type PaymentGatewayIntentProcessor struct { + logger mlogger.Logger + handler ch.PaymentGatewayIntentHandler + event model.NotificationEvent +} + +func (pgp *PaymentGatewayIntentProcessor) Process(ctx context.Context, envelope me.Envelope) error { + var msg model.PaymentGatewayIntent + if err := json.Unmarshal(envelope.GetData(), &msg); err != nil { + pgp.logger.Warn("Failed to decode payment gateway intent envelope", zap.Error(err), zap.String("topic", pgp.event.ToString())) + return err + } + if pgp.handler == nil { + pgp.logger.Warn("Payment gateway intent handler is not configured", zap.String("topic", pgp.event.ToString())) + return nil + } + return pgp.handler(ctx, &msg) +} + +func (pgp *PaymentGatewayIntentProcessor) GetSubject() model.NotificationEvent { + return pgp.event +} + +type PaymentGatewayExecutionProcessor struct { + logger mlogger.Logger + handler ch.PaymentGatewayExecutionHandler + event model.NotificationEvent +} + +func (pgp *PaymentGatewayExecutionProcessor) Process(ctx context.Context, envelope me.Envelope) error { + var msg model.PaymentGatewayExecution + if err := json.Unmarshal(envelope.GetData(), &msg); err != nil { + pgp.logger.Warn("Failed to decode payment gateway execution envelope", zap.Error(err), zap.String("topic", pgp.event.ToString())) + return err + } + if pgp.handler == nil { + pgp.logger.Warn("Payment gateway execution handler is not configured", zap.String("topic", pgp.event.ToString())) + return nil + } + return pgp.handler(ctx, &msg) +} + +func (pgp *PaymentGatewayExecutionProcessor) GetSubject() model.NotificationEvent { + return pgp.event +} + +func NewPaymentGatewayIntentProcessor(logger mlogger.Logger, handler ch.PaymentGatewayIntentHandler) np.EnvelopeProcessor { + if logger != nil { + logger = logger.Named("payment_gateway_intent_processor") + } + return &PaymentGatewayIntentProcessor{ + logger: logger, + handler: handler, + event: intentEvent(), + } +} + +func NewPaymentGatewayExecutionProcessor(logger mlogger.Logger, handler ch.PaymentGatewayExecutionHandler) np.EnvelopeProcessor { + if logger != nil { + logger = logger.Named("payment_gateway_execution_processor") + } + return &PaymentGatewayExecutionProcessor{ + logger: logger, + handler: handler, + event: executionEvent(), + } +} diff --git a/api/pkg/messaging/notifications/confirmations/confirmations.go b/api/pkg/messaging/notifications/confirmations/confirmations.go new file mode 100644 index 0000000..09b9003 --- /dev/null +++ b/api/pkg/messaging/notifications/confirmations/confirmations.go @@ -0,0 +1,26 @@ +package notifications + +import ( + messaging "github.com/tech/sendico/pkg/messaging/envelope" + cinternal "github.com/tech/sendico/pkg/messaging/internal/notifications/confirmations" + ch "github.com/tech/sendico/pkg/messaging/notifications/confirmations/handler" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +func ConfirmationRequest(sender string, request *model.ConfirmationRequest) messaging.Envelope { + return cinternal.NewConfirmationRequestEnvelope(sender, request) +} + +func ConfirmationResult(sender string, result *model.ConfirmationResult, sourceService, rail string) messaging.Envelope { + return cinternal.NewConfirmationResultEnvelope(sender, result, sourceService, rail) +} + +func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor { + return cinternal.NewConfirmationRequestProcessor(logger, handler) +} + +func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationResultHandler) np.EnvelopeProcessor { + return cinternal.NewConfirmationResultProcessor(logger, sourceService, rail, handler) +} diff --git a/api/pkg/messaging/notifications/confirmations/handler/interface.go b/api/pkg/messaging/notifications/confirmations/handler/interface.go new file mode 100644 index 0000000..7a15817 --- /dev/null +++ b/api/pkg/messaging/notifications/confirmations/handler/interface.go @@ -0,0 +1,11 @@ +package notifications + +import ( + "context" + + "github.com/tech/sendico/pkg/model" +) + +type ConfirmationRequestHandler = func(context.Context, *model.ConfirmationRequest) error + +type ConfirmationResultHandler = func(context.Context, *model.ConfirmationResult) error diff --git a/api/pkg/messaging/notifications/paymentgateway/handler/interface.go b/api/pkg/messaging/notifications/paymentgateway/handler/interface.go new file mode 100644 index 0000000..8e30a6a --- /dev/null +++ b/api/pkg/messaging/notifications/paymentgateway/handler/interface.go @@ -0,0 +1,11 @@ +package notifications + +import ( + "context" + + "github.com/tech/sendico/pkg/model" +) + +type PaymentGatewayIntentHandler = func(context.Context, *model.PaymentGatewayIntent) error + +type PaymentGatewayExecutionHandler = func(context.Context, *model.PaymentGatewayExecution) error diff --git a/api/pkg/messaging/notifications/paymentgateway/paymentgateway.go b/api/pkg/messaging/notifications/paymentgateway/paymentgateway.go new file mode 100644 index 0000000..c454809 --- /dev/null +++ b/api/pkg/messaging/notifications/paymentgateway/paymentgateway.go @@ -0,0 +1,26 @@ +package notifications + +import ( + messaging "github.com/tech/sendico/pkg/messaging/envelope" + pinternal "github.com/tech/sendico/pkg/messaging/internal/notifications/paymentgateway" + ch "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway/handler" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +func PaymentGatewayIntent(sender string, intent *model.PaymentGatewayIntent) messaging.Envelope { + return pinternal.NewPaymentGatewayIntentEnvelope(sender, intent) +} + +func PaymentGatewayExecution(sender string, exec *model.PaymentGatewayExecution) messaging.Envelope { + return pinternal.NewPaymentGatewayExecutionEnvelope(sender, exec) +} + +func NewPaymentGatewayIntentProcessor(logger mlogger.Logger, handler ch.PaymentGatewayIntentHandler) np.EnvelopeProcessor { + return pinternal.NewPaymentGatewayIntentProcessor(logger, handler) +} + +func NewPaymentGatewayExecutionProcessor(logger mlogger.Logger, handler ch.PaymentGatewayExecutionHandler) np.EnvelopeProcessor { + return pinternal.NewPaymentGatewayExecutionProcessor(logger, handler) +} diff --git a/api/pkg/model/confirmation.go b/api/pkg/model/confirmation.go index 6c369c4..6cdba30 100644 --- a/api/pkg/model/confirmation.go +++ b/api/pkg/model/confirmation.go @@ -1,38 +1,42 @@ package model -import ( - "time" +import paymenttypes "github.com/tech/sendico/pkg/payments/types" - "github.com/tech/sendico/pkg/db/storable" - "github.com/tech/sendico/pkg/mservice" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -type ConfirmationTarget string +type ConfirmationStatus string const ( - ConfirmationTargetLogin ConfirmationTarget = "login" - ConfirmationTargetPayout ConfirmationTarget = "payout" + ConfirmationStatusConfirmed ConfirmationStatus = "CONFIRMED" + ConfirmationStatusClarified ConfirmationStatus = "CLARIFIED" + ConfirmationStatusTimeout ConfirmationStatus = "TIMEOUT" + ConfirmationStatusRejected ConfirmationStatus = "REJECTED" ) -// ConfirmationCode stores verification codes for operations like login or payouts. -type ConfirmationCode struct { - storable.Base `bson:",inline" json:",inline"` - - AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"` - Destination string `bson:"destination" json:"destination"` - Target ConfirmationTarget `bson:"target" json:"target"` - CodeHash []byte `bson:"codeHash" json:"-"` - Salt []byte `bson:"salt" json:"-"` - ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` - Attempts int `bson:"attempts" json:"attempts"` - MaxAttempts int `bson:"maxAttempts" json:"maxAttempts"` - ResendCount int `bson:"resendCount" json:"resendCount"` - ResendLimit int `bson:"resendLimit" json:"resendLimit"` - CooldownUntil time.Time `bson:"cooldownUntil" json:"cooldownUntil"` - Used bool `bson:"used" json:"used"` +type ConfirmationRequest struct { + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"` + RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"` + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + AcceptedUserIDs []string `bson:"acceptedUserIds,omitempty" json:"accepted_user_ids,omitempty"` + TimeoutSeconds int32 `bson:"timeoutSeconds,omitempty" json:"timeout_seconds,omitempty"` + SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"` + Rail string `bson:"rail,omitempty" json:"rail,omitempty"` } -func (c *ConfirmationCode) Collection() string { - return mservice.Confirmations +type ConfirmationResult struct { + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + Money *paymenttypes.Money `bson:"money,omitempty" json:"money,omitempty"` + RawReply *TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"` + Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"` + ParseError string `bson:"parseError,omitempty" json:"parse_error,omitempty"` +} + +type TelegramMessage struct { + ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"` + MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"` + ReplyToMessageID string `bson:"replyToMessageId,omitempty" json:"reply_to_message_id,omitempty"` + FromUserID string `bson:"fromUserId,omitempty" json:"from_user_id,omitempty"` + FromUsername string `bson:"fromUsername,omitempty" json:"from_username,omitempty"` + Text string `bson:"text,omitempty" json:"text,omitempty"` + SentAt int64 `bson:"sentAt,omitempty" json:"sent_at,omitempty"` } diff --git a/api/pkg/model/confirmation_code.go b/api/pkg/model/confirmation_code.go new file mode 100644 index 0000000..326a080 --- /dev/null +++ b/api/pkg/model/confirmation_code.go @@ -0,0 +1,36 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ConfirmationTarget string + +const ( + ConfirmationTargetLogin ConfirmationTarget = "login" + ConfirmationTargetPayout ConfirmationTarget = "payout" +) + +type ConfirmationCode struct { + storable.Base `bson:",inline" json:",inline"` + AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"` + Destination string `bson:"destination" json:"destination"` + Target ConfirmationTarget `bson:"target" json:"target"` + CodeHash []byte `bson:"codeHash" json:"codeHash,omitempty"` + Salt []byte `bson:"salt" json:"salt,omitempty"` + ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` + MaxAttempts int `bson:"maxAttempts" json:"maxAttempts"` + ResendLimit int `bson:"resendLimit" json:"resendLimit"` + CooldownUntil time.Time `bson:"cooldownUntil" json:"cooldownUntil"` + Used bool `bson:"used" json:"used"` + Attempts int `bson:"attempts" json:"attempts"` + ResendCount int `bson:"resendCount" json:"resendCount"` +} + +func (*ConfirmationCode) Collection() string { + return mservice.Confirmations +} diff --git a/api/pkg/model/notification/notification.go b/api/pkg/model/notification/notification.go index 0d08515..a3f4da0 100644 --- a/api/pkg/model/notification/notification.go +++ b/api/pkg/model/notification/notification.go @@ -12,6 +12,10 @@ const ( NASent NotificationAction = "sent" NAPasswordReset NotificationAction = "password_reset" + NAConfirmationRequest NotificationAction = "confirmation.request" + NAPaymentGatewayIntent NotificationAction = "intent.request" + NAPaymentGatewayExecution NotificationAction = "execution.result" + NADiscoveryServiceAnnounce NotificationAction = "service.announce" NADiscoveryGatewayAnnounce NotificationAction = "gateway.announce" NADiscoveryHeartbeat NotificationAction = "service.heartbeat" diff --git a/api/pkg/model/notificationevent.go b/api/pkg/model/notificationevent.go index 9208114..58c7d5c 100644 --- a/api/pkg/model/notificationevent.go +++ b/api/pkg/model/notificationevent.go @@ -81,6 +81,9 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) { nm.NADeleted, nm.NAAssigned, nm.NAPasswordReset, + nm.NAConfirmationRequest, + nm.NAPaymentGatewayIntent, + nm.NAPaymentGatewayExecution, nm.NADiscoveryServiceAnnounce, nm.NADiscoveryGatewayAnnounce, nm.NADiscoveryHeartbeat, @@ -99,8 +102,15 @@ func StringToNotificationEvent(eventType, eventAction string) (NotificationEvent return nil, err } ea, err := StringToNotificationAction(eventAction) - if err != nil { - return nil, err + if err == nil { + return NewNotification(et, ea), nil } - return NewNotification(et, ea), nil + if et == mservice.Confirmations { + action := strings.TrimSpace(eventAction) + if action == "" { + return nil, err + } + return &NotificationEventImp{nType: et, nAction: nm.NotificationAction(action)}, nil + } + return nil, err } diff --git a/api/pkg/model/payment_gateway.go b/api/pkg/model/payment_gateway.go new file mode 100644 index 0000000..8c6bd46 --- /dev/null +++ b/api/pkg/model/payment_gateway.go @@ -0,0 +1,22 @@ +package model + +import paymenttypes "github.com/tech/sendico/pkg/payments/types" + +type PaymentGatewayIntent struct { + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"` + TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"` +} + +type PaymentGatewayExecution struct { + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"` + Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"` + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + RawReply *TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"` +} diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index 1571cd3..49d882c 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -14,6 +14,7 @@ const ( Clients Type = "clients" // Represents client information ChainGateway Type = "chain_gateway" // Represents chain gateway microservice MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice + PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice FXOracle Type = "fx_oracle" // Represents FX oracle microservice FeePlans Type = "fee_plans" // Represents fee plans microservice FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources @@ -36,6 +37,7 @@ const ( Organizations Type = "organizations" // Represents organizations in the system Payments Type = "payments" // Represents payments service PaymentRoutes Type = "payment_routes" // Represents payment routing definitions + PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates PaymentMethods Type = "payment_methods" // Represents payment methods service Permissions Type = "permissions" // Represents permissiosns service Policies Type = "policies" // Represents access control policies @@ -52,9 +54,9 @@ const ( func StringToSType(s string) (Type, error) { switch Type(s) { case Accounts, Confirmations, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, ChainWalletBalances, - ChainTransfers, ChainDeposits, MntxGateway, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger, + ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger, LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, - Organizations, Payments, PaymentRoutes, PaymentOrchestrator, Permissions, Policies, PolicyAssignements, + Organizations, Payments, PaymentRoutes, PaymentPlanTemplates, PaymentOrchestrator, Permissions, Policies, PolicyAssignements, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery: return Type(s), nil default: diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index aa6ce76..80d91e7 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -92,6 +92,7 @@ message PaymentEndpoint { CardEndpoint card = 4; } map metadata = 10; + string instance_id = 11; } message FXIntent { @@ -114,6 +115,7 @@ message PaymentIntent { map attributes = 8; SettlementMode settlement_mode = 9; Customer customer = 10; + string settlement_currency = 11; } message Customer { @@ -178,6 +180,11 @@ message PaymentStep { common.gateway.v1.RailOperation action = 3; common.money.v1.Money amount = 4; string ref = 5; + string step_id = 6; + string instance_id = 7; + repeated string depends_on = 8; + string commit_policy = 9; + repeated string commit_after = 10; } message PaymentPlan { @@ -185,6 +192,8 @@ message PaymentPlan { repeated PaymentStep steps = 2; string idempotency_key = 3; google.protobuf.Timestamp created_at = 4; + oracle.v1.Quote fx_quote = 5; + repeated fees.v1.DerivedPostingLine fees = 6; } // Card payout gateway tracking info. diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index 0042903..a5901d3 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -160,3 +160,19 @@ CHAIN_GATEWAY_MONGO_PORT=27017 CHAIN_GATEWAY_MONGO_DATABASE=chain_gateway CHAIN_GATEWAY_MONGO_AUTH_SOURCE=admin CHAIN_GATEWAY_MONGO_REPLICA_SET=sendico-rs + +# TGSettle gateway stack +TGSETTLE_GATEWAY_DIR=tgsettle_gateway +TGSETTLE_GATEWAY_COMPOSE_PROJECT=sendico-tgsettle-gateway +TGSETTLE_GATEWAY_SERVICE_NAME=sendico_tgsettle_gateway +TGSETTLE_GATEWAY_GRPC_PORT=50080 +TGSETTLE_GATEWAY_METRICS_PORT=9406 +# Update TGSETTLE_GATEWAY_CHAT_ID with the Telegram group id for confirmations. +TGSETTLE_GATEWAY_CHAT_ID=-100 + +# TGSettle gateway Mongo settings +TGSETTLE_GATEWAY_MONGO_HOST=sendico_db1 +TGSETTLE_GATEWAY_MONGO_PORT=27017 +TGSETTLE_GATEWAY_MONGO_DATABASE=tgsettle_gateway +TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE=admin +TGSETTLE_GATEWAY_MONGO_REPLICA_SET=sendico-rs diff --git a/ci/prod/compose/tgsettle_gateway.dockerfile b/ci/prod/compose/tgsettle_gateway.dockerfile new file mode 100644 index 0000000..53459eb --- /dev/null +++ b/ci/prod/compose/tgsettle_gateway.dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.7 + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +FROM golang:alpine AS build +ARG APP_VERSION=dev +ARG GIT_REV=unknown +ARG BUILD_BRANCH=unknown +ARG BUILD_DATE=unknown +ARG BUILD_USER=ci +ENV GO111MODULE=on +ENV PATH="/go/bin:${PATH}" +WORKDIR /src +COPY . . +RUN apk add --no-cache bash git build-base protoc protobuf-dev \ + && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \ + && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest \ + && bash ci/scripts/proto/generate.sh +WORKDIR /src/api/gateway/tgsettle +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "\ + -s -w \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.Version=${APP_VERSION} \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.Revision=${GIT_REV} \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.Branch=${BUILD_BRANCH} \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.BuildUser=${BUILD_USER} \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.BuildDate=${BUILD_DATE}" \ + -o /out/tgsettle-gateway . + +FROM alpine:latest AS runtime +RUN apk add --no-cache ca-certificates tzdata wget +WORKDIR /app +COPY api/gateway/tgsettle/config.yml /app/config.yml +COPY --from=build /out/tgsettle-gateway /app/tgsettle-gateway +EXPOSE 50080 9406 +ENTRYPOINT ["/app/tgsettle-gateway"] +CMD ["--config.file", "/app/config.yml"] diff --git a/ci/prod/compose/tgsettle_gateway.yml b/ci/prod/compose/tgsettle_gateway.yml new file mode 100644 index 0000000..85cd61a --- /dev/null +++ b/ci/prod/compose/tgsettle_gateway.yml @@ -0,0 +1,53 @@ +# Compose v2 - TGSettle Gateway + +x-common-env: &common-env + env_file: + - ../env/.env.runtime + - ../env/.env.version + +networks: + sendico-net: + external: true + name: sendico-net + +services: + sendico_tgsettle_gateway: + <<: *common-env + container_name: sendico-tgsettle-gateway + restart: unless-stopped + image: ${REGISTRY_URL}/gateway/tgsettle:${APP_V} + pull_policy: always + environment: + TGSETTLE_GATEWAY_MONGO_HOST: ${TGSETTLE_GATEWAY_MONGO_HOST} + TGSETTLE_GATEWAY_MONGO_PORT: ${TGSETTLE_GATEWAY_MONGO_PORT} + TGSETTLE_GATEWAY_MONGO_DATABASE: ${TGSETTLE_GATEWAY_MONGO_DATABASE} + TGSETTLE_GATEWAY_MONGO_USER: ${TGSETTLE_GATEWAY_MONGO_USER} + TGSETTLE_GATEWAY_MONGO_PASSWORD: ${TGSETTLE_GATEWAY_MONGO_PASSWORD} + TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE: ${TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE} + TGSETTLE_GATEWAY_MONGO_REPLICA_SET: ${TGSETTLE_GATEWAY_MONGO_REPLICA_SET} + MONGO_HOSTS_0: ${MONGO_HOSTS_0} + MONGO_PORTS_0: ${MONGO_PORTS_0} + MONGO_HOSTS_1: ${MONGO_HOSTS_1} + MONGO_PORTS_1: ${MONGO_PORTS_1} + MONGO_HOSTS_2: ${MONGO_HOSTS_2} + MONGO_PORTS_2: ${MONGO_PORTS_2} + NATS_URL: ${NATS_URL} + NATS_HOST: ${NATS_HOST} + NATS_PORT: ${NATS_PORT} + NATS_USER: ${NATS_USER} + NATS_PASSWORD: ${NATS_PASSWORD} + TGSETTLE_GATEWAY_CHAT_ID: ${TGSETTLE_GATEWAY_CHAT_ID} + TGSETTLE_GATEWAY_GRPC_PORT: ${TGSETTLE_GATEWAY_GRPC_PORT} + TGSETTLE_GATEWAY_METRICS_PORT: ${TGSETTLE_GATEWAY_METRICS_PORT} + command: ["--config.file", "/app/config.yml"] + ports: + - "0.0.0.0:${TGSETTLE_GATEWAY_GRPC_PORT}:50080" + - "0.0.0.0:${TGSETTLE_GATEWAY_METRICS_PORT}:9406" + healthcheck: + test: ["CMD-SHELL","wget -qO- http://localhost:9406/health | grep -q '\"status\":\"ok\"'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - sendico-net diff --git a/ci/prod/scripts/deploy/tgsettle_gateway.sh b/ci/prod/scripts/deploy/tgsettle_gateway.sh new file mode 100755 index 0000000..2b4e9ba --- /dev/null +++ b/ci/prod/scripts/deploy/tgsettle_gateway.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x +trap 'echo "[deploy-tgsettle-gateway] error at line $LINENO" >&2' ERR + +: "${REMOTE_BASE:?missing REMOTE_BASE}" +: "${SSH_USER:?missing SSH_USER}" +: "${SSH_HOST:?missing SSH_HOST}" +: "${TGSETTLE_GATEWAY_DIR:?missing TGSETTLE_GATEWAY_DIR}" +: "${TGSETTLE_GATEWAY_COMPOSE_PROJECT:?missing TGSETTLE_GATEWAY_COMPOSE_PROJECT}" +: "${TGSETTLE_GATEWAY_SERVICE_NAME:?missing TGSETTLE_GATEWAY_SERVICE_NAME}" + +REMOTE_DIR="${REMOTE_BASE%/}/${TGSETTLE_GATEWAY_DIR}" +REMOTE_TARGET="${SSH_USER}@${SSH_HOST}" +COMPOSE_FILE="tgsettle_gateway.yml" +SERVICE_NAMES="${TGSETTLE_GATEWAY_SERVICE_NAME}" + +REQUIRED_SECRETS=( + TGSETTLE_GATEWAY_MONGO_USER + TGSETTLE_GATEWAY_MONGO_PASSWORD + NATS_USER + NATS_PASSWORD + NATS_URL +) + +for var in "${REQUIRED_SECRETS[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "missing required secret env: ${var}" >&2 + exit 65 + fi +done + +if [[ ! -s .env.version ]]; then + echo ".env.version is missing; run version step first" >&2 + exit 66 +fi + +b64enc() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +TGSETTLE_GATEWAY_MONGO_USER_B64="$(b64enc "${TGSETTLE_GATEWAY_MONGO_USER}")" +TGSETTLE_GATEWAY_MONGO_PASSWORD_B64="$(b64enc "${TGSETTLE_GATEWAY_MONGO_PASSWORD}")" +NATS_USER_B64="$(b64enc "${NATS_USER}")" +NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")" +NATS_URL_B64="$(b64enc "${NATS_URL}")" + +SSH_OPTS=( + -i /root/.ssh/id_rsa + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o LogLevel=ERROR + -q +) +if [[ "${DEBUG_DEPLOY:-0}" = "1" ]]; then + SSH_OPTS=("${SSH_OPTS[@]/-q/}" -vv) +fi + +RSYNC_FLAGS=(-az --delete) +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && RSYNC_FLAGS=(-avz --delete) + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p ${REMOTE_DIR}/{compose,env}" + +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/compose/ "$REMOTE_TARGET:${REMOTE_DIR}/compose/" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/.env.runtime "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.runtime" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" .env.version "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.version" + +SERVICES_LINE="${SERVICE_NAMES}" + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \ + REMOTE_DIR="$REMOTE_DIR" \ + COMPOSE_FILE="$COMPOSE_FILE" \ + COMPOSE_PROJECT="$TGSETTLE_GATEWAY_COMPOSE_PROJECT" \ + SERVICES_LINE="$SERVICES_LINE" \ + TGSETTLE_GATEWAY_MONGO_USER_B64="$TGSETTLE_GATEWAY_MONGO_USER_B64" \ + TGSETTLE_GATEWAY_MONGO_PASSWORD_B64="$TGSETTLE_GATEWAY_MONGO_PASSWORD_B64" \ + NATS_USER_B64="$NATS_USER_B64" \ + NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \ + NATS_URL_B64="$NATS_URL_B64" \ + bash -s <<'EOSSH' +set -euo pipefail +cd "${REMOTE_DIR}/compose" +set -a +. ../env/.env.runtime +load_kv_file() { + local file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then + local key="${line%%=*}" + local value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ -n "$key" ]]; then + export "$key=$value" + fi + fi + done <"$file" +} +load_kv_file ../env/.env.version +set +a + +if base64 -d >/dev/null 2>&1 <<<'AA=='; then + BASE64_DECODE_FLAG='-d' +else + BASE64_DECODE_FLAG='--decode' +fi + +decode_b64() { + val="$1" + if [[ -z "$val" ]]; then + printf '' + return + fi + printf '%s' "$val" | base64 "${BASE64_DECODE_FLAG}" +} + +TGSETTLE_GATEWAY_MONGO_USER="$(decode_b64 "$TGSETTLE_GATEWAY_MONGO_USER_B64")" +TGSETTLE_GATEWAY_MONGO_PASSWORD="$(decode_b64 "$TGSETTLE_GATEWAY_MONGO_PASSWORD_B64")" +NATS_USER="$(decode_b64 "$NATS_USER_B64")" +NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")" +NATS_URL="$(decode_b64 "$NATS_URL_B64")" + +export TGSETTLE_GATEWAY_MONGO_USER TGSETTLE_GATEWAY_MONGO_PASSWORD NATS_USER NATS_PASSWORD NATS_URL +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" +export COMPOSE_PROJECT_NAME +read -r -a SERVICES <<<"${SERVICES_LINE}" + +pull_cmd=(docker compose -f "$COMPOSE_FILE" pull) +up_cmd=(docker compose -f "$COMPOSE_FILE" up -d --remove-orphans) +ps_cmd=(docker compose -f "$COMPOSE_FILE" ps) +if [[ "${#SERVICES[@]}" -gt 0 ]]; then + pull_cmd+=("${SERVICES[@]}") + up_cmd+=("${SERVICES[@]}") + ps_cmd+=("${SERVICES[@]}") +fi + +"${pull_cmd[@]}" +"${up_cmd[@]}" +"${ps_cmd[@]}" + +date -Is > .last_deploy +logger -t "deploy-${COMPOSE_PROJECT_NAME}" "${COMPOSE_PROJECT_NAME} deployed at $(date -Is) in ${REMOTE_DIR}" +EOSSH diff --git a/ci/scripts/tgsettle/build-image.sh b/ci/scripts/tgsettle/build-image.sh new file mode 100755 index 0000000..cef8f1a --- /dev/null +++ b/ci/scripts/tgsettle/build-image.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +TGSETTLE_GATEWAY_ENV_NAME="${TGSETTLE_GATEWAY_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${TGSETTLE_GATEWAY_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[tgsettle-gateway-build] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +REGISTRY_URL="${REGISTRY_URL:?missing REGISTRY_URL}" +APP_V="${APP_V:?missing APP_V}" +TGSETTLE_GATEWAY_DOCKERFILE="${TGSETTLE_GATEWAY_DOCKERFILE:?missing TGSETTLE_GATEWAY_DOCKERFILE}" +TGSETTLE_GATEWAY_IMAGE_PATH="${TGSETTLE_GATEWAY_IMAGE_PATH:?missing TGSETTLE_GATEWAY_IMAGE_PATH}" + +REGISTRY_HOST="${REGISTRY_URL#http://}" +REGISTRY_HOST="${REGISTRY_HOST#https://}" +REGISTRY_USER="$(cat secrets/REGISTRY_USER)" +REGISTRY_PASSWORD="$(cat secrets/REGISTRY_PASSWORD)" +: "${REGISTRY_USER:?missing registry user}" +: "${REGISTRY_PASSWORD:?missing registry password}" + +mkdir -p /kaniko/.docker +AUTH_B64="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" +cat </kaniko/.docker/config.json +{ + "auths": { + "https://${REGISTRY_HOST}": { "auth": "${AUTH_B64}" } + } +} +EOF + +BUILD_CONTEXT="${TGSETTLE_GATEWAY_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}" +if [ ! -d "${BUILD_CONTEXT}" ]; then + BUILD_CONTEXT="/workspace" +fi + +/kaniko/executor \ + --context "${BUILD_CONTEXT}" \ + --dockerfile "${TGSETTLE_GATEWAY_DOCKERFILE}" \ + --destination "${REGISTRY_URL}/${TGSETTLE_GATEWAY_IMAGE_PATH}:${APP_V}" \ + --build-arg APP_VERSION="${APP_V}" \ + --build-arg GIT_REV="${GIT_REV}" \ + --build-arg BUILD_BRANCH="${BUILD_BRANCH}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg BUILD_USER="${BUILD_USER}" \ + --single-snapshot diff --git a/ci/scripts/tgsettle/deploy.sh b/ci/scripts/tgsettle/deploy.sh new file mode 100755 index 0000000..3f10501 --- /dev/null +++ b/ci/scripts/tgsettle/deploy.sh @@ -0,0 +1,61 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +TGSETTLE_GATEWAY_ENV_NAME="${TGSETTLE_GATEWAY_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${TGSETTLE_GATEWAY_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[tgsettle-gateway-deploy] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +TGSETTLE_GATEWAY_MONGO_SECRET_PATH="${TGSETTLE_GATEWAY_MONGO_SECRET_PATH:?missing TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" +: "${NATS_HOST:?missing NATS_HOST}" +: "${NATS_PORT:?missing NATS_PORT}" + +export TGSETTLE_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" user)" +export TGSETTLE_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" password)" + +export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" +export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)" +export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}" + +bash ci/prod/scripts/bootstrap/network.sh +bash ci/prod/scripts/deploy/tgsettle_gateway.sh -- 2.49.1 From 59c83e414ac6f7d5fcbef04e630b8f2c080184c4 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sun, 4 Jan 2026 12:47:43 +0100 Subject: [PATCH 4/4] unified gateway interfaces --- api/gateway/chain/client/client.go | 3 +- api/gateway/chain/go.mod | 2 +- api/gateway/chain/go.sum | 4 +- .../chain/internal/service/gateway/service.go | 8 +- api/gateway/mntx/client/client.go | 5 +- api/gateway/mntx/go.mod | 3 +- api/gateway/mntx/go.sum | 6 +- .../mntx/internal/service/gateway/service.go | 6 +- api/gateway/tgsettle/config.yml | 2 +- api/gateway/tgsettle/go.mod | 4 +- .../internal/service/gateway/service.go | 237 ++++++++++++++++-- api/ledger/client/client.go | 3 +- api/ledger/internal/service/ledger/service.go | 6 +- api/notification/go.mod | 2 +- api/notification/go.sum | 10 +- api/payments/orchestrator/config.yml | 6 + .../internal/server/internal/builders.go | 25 +- .../internal/server/internal/clients.go | 26 ++ .../internal/server/internal/config.go | 1 + .../internal/server/internal/dependencies.go | 23 +- .../internal/server/internal/types.go | 23 +- .../gateway_execution_consumer.go | 67 ++++- .../service/orchestrator/gateway_registry.go | 32 +-- .../internal/service/orchestrator/options.go | 55 +++- .../payment_plan_executor_test.go | 2 +- .../orchestrator/payment_plan_steps.go | 27 ++ .../orchestrator/provider_settlement.go | 113 +++++++++ .../provider_settlement_gateway.go | 164 ++++++++++++ .../internal/service/orchestrator/service.go | 1 + .../service/orchestrator/service_test.go | 2 +- api/proto/gateway/chain/v1/chain.proto | 16 -- api/proto/gateway/mntx/v1/mntx.proto | 8 - api/proto/gateway/unified/v1/gateway.proto | 45 ++++ api/proto/ledger/v1/ledger.proto | 16 -- .../interface/api/srequest/payment_intent.go | 25 +- .../internal/server/paymentapiimp/mapper.go | 70 +++++- ci/scripts/proto/generate.sh | 6 + .../lib/data/dto/payment/intent/payment.dart | 4 + .../data/mapper/payment/intent/payment.dart | 2 + .../pshared/lib/models/payment/intent.dart | 2 + .../lib/provider/payment/quotation.dart | 51 +++- 41 files changed, 927 insertions(+), 186 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go create mode 100644 api/proto/gateway/unified/v1/gateway.proto diff --git a/api/gateway/chain/client/client.go b/api/gateway/chain/client/client.go index 71c3a22..f7676ed 100644 --- a/api/gateway/chain/client/client.go +++ b/api/gateway/chain/client/client.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/pkg/merrors" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" @@ -75,7 +76,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro return &chainGatewayClient{ cfg: cfg, conn: conn, - client: chainv1.NewChainGatewayServiceClient(conn), + client: unifiedv1.NewUnifiedGatewayServiceClient(conn), }, nil } diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 63a2941..3ed2fe6 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-20251230134950-44c893854e3f // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358 // 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 c73ea16..3e87ffe 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-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/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358 h1:B6uGMdZ4maUTJm+LYgBwEIDuJxgOUACw8K0Yg6jpNbY= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358/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/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index 1768ad0..2c05d45 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -20,6 +20,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "google.golang.org/grpc" ) @@ -33,7 +34,7 @@ var ( errStorageUnavailable = serviceError("chain_gateway: storage not initialised") ) -// Service implements the ChainGatewayService RPC contract. +// Service implements the UnifiedGatewayService RPC contract for chain operations. type Service struct { logger mlogger.Logger storage storage.Repository @@ -51,7 +52,7 @@ type Service struct { commands commands.Registry announcers []*discovery.Announcer - chainv1.UnimplementedChainGatewayServiceServer + unifiedv1.UnimplementedUnifiedGatewayServiceServer } // NewService constructs the chain gateway service skeleton. @@ -94,7 +95,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro // Register wires the service onto the provided gRPC router. func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { - chainv1.RegisterChainGatewayServiceServer(reg, s) + unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) }) } @@ -208,6 +209,7 @@ func (s *Service) startDiscoveryAnnouncers() { Network: network.Name, Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send"}, Currencies: currencies, + InvokeURI: discovery.DefaultInvokeURI(string(mservice.ChainGateway)), Version: version, } announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce) diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go index ef57b18..b1373f8 100644 --- a/api/gateway/mntx/client/client.go +++ b/api/gateway/mntx/client/client.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/pkg/merrors" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "go.uber.org/zap" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -23,7 +24,7 @@ type Client interface { type gatewayClient struct { conn *grpc.ClientConn - client mntxv1.MntxGatewayServiceClient + client unifiedv1.UnifiedGatewayServiceClient cfg Config logger *zap.Logger } @@ -48,7 +49,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro return &gatewayClient{ conn: conn, - client: mntxv1.NewMntxGatewayServiceClient(conn), + client: unifiedv1.NewUnifiedGatewayServiceClient(conn), cfg: cfg, logger: cfg.Logger, }, nil diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index 3abc45c..f026a89 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -7,7 +7,6 @@ replace github.com/tech/sendico/pkg => ../../pkg require ( github.com/go-chi/chi/v5 v5.2.3 github.com/prometheus/client_golang v1.23.2 - github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/pkg v0.1.0 go.uber.org/zap v1.27.1 google.golang.org/grpc v1.78.0 @@ -22,8 +21,8 @@ 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/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index d72371c..cdcb4af 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -40,8 +40,6 @@ 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= @@ -59,6 +57,8 @@ 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= @@ -125,8 +125,6 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index d06e5ae..02a9360 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -16,6 +16,7 @@ import ( "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" + unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "go.uber.org/zap" "google.golang.org/grpc" ) @@ -31,7 +32,7 @@ type Service struct { gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor announcer *discovery.Announcer - mntxv1.UnimplementedMntxGatewayServiceServer + unifiedv1.UnimplementedUnifiedGatewayServiceServer } type payoutFailure interface { @@ -96,7 +97,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service { // Register wires the service onto the provided gRPC router. func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { - mntxv1.RegisterMntxGatewayServiceServer(reg, s) + unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) }) } @@ -145,6 +146,7 @@ func (s *Service) startDiscoveryAnnouncer() { Service: "CARD_PAYOUT_RAIL_GATEWAY", Rail: "CARD_PAYOUT", Operations: []string{"payout.card"}, + InvokeURI: discovery.DefaultInvokeURI(string(mservice.MntxGateway)), Version: appversion.Create().Short(), } if s.gatewayDescriptor != nil { diff --git a/api/gateway/tgsettle/config.yml b/api/gateway/tgsettle/config.yml index 7a10c6e..cfa5e01 100644 --- a/api/gateway/tgsettle/config.yml +++ b/api/gateway/tgsettle/config.yml @@ -34,7 +34,7 @@ messaging: reconnect_wait: 5 gateway: - rail: "card" + rail: "provider_settlement" target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID timeout_seconds: 120 accepted_user_ids: [] diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod index fa9e07e..6581573 100644 --- a/api/gateway/tgsettle/go.mod +++ b/api/gateway/tgsettle/go.mod @@ -8,6 +8,8 @@ require ( github.com/tech/sendico/pkg v0.1.0 go.mongodb.org/mongo-driver v1.17.6 go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -46,6 +48,4 @@ require ( golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect - google.golang.org/grpc v1.78.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index ec4e5ad..1a7a7fd 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -20,8 +20,16 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" + 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" + unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "github.com/tech/sendico/pkg/server/grpcapp" "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" ) const ( @@ -29,6 +37,13 @@ const ( executedStatus = "executed" ) +const ( + metadataPaymentIntentID = "payment_intent_id" + metadataQuoteRef = "quote_ref" + metadataTargetChatID = "target_chat_id" + metadataOutgoingLeg = "outgoing_leg" +) + type Config struct { Rail string TargetChatIDEnv string @@ -49,6 +64,8 @@ type Service struct { mu sync.Mutex pending map[string]*model.PaymentGatewayIntent consumers []msg.Consumer + + unifiedv1.UnimplementedUnifiedGatewayServiceServer } func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service { @@ -56,13 +73,13 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro logger = logger.Named("tgsettle_gateway") } svc := &Service{ - logger: logger, - repo: repo, + logger: logger, + repo: repo, producer: producer, - broker: broker, - cfg: cfg, - rail: strings.TrimSpace(cfg.Rail), - pending: map[string]*model.PaymentGatewayIntent{}, + broker: broker, + cfg: cfg, + rail: strings.TrimSpace(cfg.Rail), + pending: map[string]*model.PaymentGatewayIntent{}, } svc.chatID = strings.TrimSpace(readEnv(cfg.TargetChatIDEnv)) svc.startConsumers() @@ -70,8 +87,10 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro return svc } -func (s *Service) Register(_ routers.GRPC) error { - return nil +func (s *Service) Register(router routers.GRPC) error { + return router.Register(func(reg grpc.ServiceRegistrar) { + unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) + }) } func (s *Service) Shutdown() { @@ -95,8 +114,6 @@ func (s *Service) startConsumers() { } return } - intentProcessor := paymentgateway.NewPaymentGatewayIntentProcessor(s.logger, s.onIntent) - s.consumeProcessor(intentProcessor) resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult) s.consumeProcessor(resultProcessor) } @@ -115,6 +132,62 @@ func (s *Service) consumeProcessor(processor np.EnvelopeProcessor) { }() } +func (s *Service) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + if req == nil { + return nil, merrors.InvalidArgument("submit_transfer: request is required") + } + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + if idempotencyKey == "" { + return nil, merrors.InvalidArgument("submit_transfer: idempotency_key is required") + } + amount := req.GetAmount() + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("submit_transfer: amount is required") + } + intent, err := intentFromSubmitTransfer(req, s.rail, s.chatID) + if err != nil { + return nil, err + } + if s.repo == nil || s.repo.Payments() == nil { + return nil, merrors.Internal("payment gateway storage unavailable") + } + existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, idempotencyKey) + if err != nil { + return nil, err + } + if existing != nil { + return &chainv1.SubmitTransferResponse{Transfer: transferFromExecution(existing, req)}, nil + } + if err := s.onIntent(ctx, intent); err != nil { + return nil, err + } + return &chainv1.SubmitTransferResponse{Transfer: transferFromRequest(req)}, nil +} + +func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) { + if req == nil { + return nil, merrors.InvalidArgument("get_transfer: request is required") + } + transferRef := strings.TrimSpace(req.GetTransferRef()) + if transferRef == "" { + return nil, merrors.InvalidArgument("get_transfer: transfer_ref is required") + } + if s.repo == nil || s.repo.Payments() == nil { + return nil, merrors.Internal("payment gateway storage unavailable") + } + existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, transferRef) + if err != nil { + return nil, err + } + if existing != nil { + return &chainv1.GetTransferResponse{Transfer: transferFromExecution(existing, nil)}, nil + } + if s.hasPending(transferRef) { + return &chainv1.GetTransferResponse{Transfer: transferPending(transferRef)}, nil + } + return nil, status.Error(codes.NotFound, "transfer not found") +} + func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayIntent) error { if intent == nil { return merrors.InvalidArgument("payment gateway intent is nil", "intent") @@ -178,11 +251,11 @@ func (s *Service) onConfirmationResult(ctx context.Context, result *model.Confir if result.Status == model.ConfirmationStatusConfirmed || result.Status == model.ConfirmationStatusClarified { exec := &storagemodel.PaymentExecution{ - IdempotencyKey: intent.IdempotencyKey, + IdempotencyKey: intent.IdempotencyKey, PaymentIntentID: intent.PaymentIntentID, - ExecutedMoney: result.Money, - QuoteRef: intent.QuoteRef, - Status: executedStatus, + ExecutedMoney: result.Money, + QuoteRef: intent.QuoteRef, + Status: executedStatus, } if err := s.repo.Payments().InsertExecution(ctx, exec); err != nil && err != storage.ErrDuplicate { return err @@ -290,11 +363,22 @@ func (s *Service) removeIntent(requestID string) { s.mu.Unlock() } +func (s *Service) hasPending(requestID string) bool { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.pending[requestID] + return ok +} + func (s *Service) startAnnouncer() { if s == nil || s.producer == nil { return } - caps := []string{"telegram_confirmation", "money_persistence"} + caps := []string{"telegram_confirmation", "money_persistence", "observe.confirm", "payout.fiat"} if s.rail != "" { caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail)) } @@ -302,6 +386,7 @@ func (s *Service) startAnnouncer() { Service: string(mservice.PaymentGateway), Rail: s.rail, Operations: caps, + InvokeURI: discovery.DefaultInvokeURI(string(mservice.PaymentGateway)), } s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.PaymentGateway), announce) s.announcer.Start() @@ -324,6 +409,128 @@ func normalizeIntent(intent *model.PaymentGatewayIntent) *model.PaymentGatewayIn return &cp } +func intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, defaultChatID string) (*model.PaymentGatewayIntent, error) { + if req == nil { + return nil, merrors.InvalidArgument("submit_transfer: request is required") + } + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + if idempotencyKey == "" { + return nil, merrors.InvalidArgument("submit_transfer: idempotency_key is required") + } + amount := req.GetAmount() + if amount == nil { + return nil, merrors.InvalidArgument("submit_transfer: amount is required") + } + requestedMoney := &paymenttypes.Money{ + Amount: strings.TrimSpace(amount.GetAmount()), + Currency: strings.TrimSpace(amount.GetCurrency()), + } + if requestedMoney.Amount == "" || requestedMoney.Currency == "" { + return nil, merrors.InvalidArgument("submit_transfer: amount is required") + } + metadata := req.GetMetadata() + paymentIntentID := strings.TrimSpace(req.GetClientReference()) + if paymentIntentID == "" { + paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID]) + } + if paymentIntentID == "" { + return nil, merrors.InvalidArgument("submit_transfer: payment_intent_id is required") + } + quoteRef := strings.TrimSpace(metadata[metadataQuoteRef]) + targetChatID := strings.TrimSpace(metadata[metadataTargetChatID]) + outgoingLeg := strings.TrimSpace(metadata[metadataOutgoingLeg]) + if outgoingLeg == "" { + outgoingLeg = strings.TrimSpace(defaultRail) + } + if targetChatID == "" { + targetChatID = strings.TrimSpace(defaultChatID) + } + return &model.PaymentGatewayIntent{ + PaymentIntentID: paymentIntentID, + IdempotencyKey: idempotencyKey, + OutgoingLeg: outgoingLeg, + QuoteRef: quoteRef, + RequestedMoney: requestedMoney, + TargetChatID: targetChatID, + }, nil +} + +func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer { + if req == nil { + return nil + } + amount := req.GetAmount() + return &chainv1.Transfer{ + TransferRef: strings.TrimSpace(req.GetIdempotencyKey()), + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()), + SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()), + Destination: req.GetDestination(), + RequestedAmount: amount, + Status: chainv1.TransferStatus_TRANSFER_SUBMITTED, + } +} + +func transferFromExecution(exec *storagemodel.PaymentExecution, req *chainv1.SubmitTransferRequest) *chainv1.Transfer { + if exec == nil { + return nil + } + var requested *moneyv1.Money + if req != nil && req.GetAmount() != nil { + requested = req.GetAmount() + } + net := moneyFromPayment(exec.ExecutedMoney) + status := chainv1.TransferStatus_TRANSFER_CONFIRMED + if strings.TrimSpace(exec.Status) != "" && !strings.EqualFold(exec.Status, executedStatus) { + status = chainv1.TransferStatus_TRANSFER_PENDING + } + transfer := &chainv1.Transfer{ + TransferRef: strings.TrimSpace(exec.IdempotencyKey), + IdempotencyKey: strings.TrimSpace(exec.IdempotencyKey), + RequestedAmount: requested, + NetAmount: net, + Status: status, + } + if req != nil { + transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef()) + transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef()) + transfer.Destination = req.GetDestination() + } + if !exec.ExecutedAt.IsZero() { + ts := timestamppb.New(exec.ExecutedAt) + transfer.CreatedAt = ts + transfer.UpdatedAt = ts + } + return transfer +} + +func transferPending(requestID string) *chainv1.Transfer { + ref := strings.TrimSpace(requestID) + if ref == "" { + return nil + } + return &chainv1.Transfer{ + TransferRef: ref, + IdempotencyKey: ref, + Status: chainv1.TransferStatus_TRANSFER_SUBMITTED, + } +} + +func moneyFromPayment(m *paymenttypes.Money) *moneyv1.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.Currency) + amount := strings.TrimSpace(m.Amount) + if currency == "" || amount == "" { + return nil + } + return &moneyv1.Money{ + Currency: currency, + Amount: amount, + } +} + func readEnv(env string) string { if strings.TrimSpace(env) == "" { return "" diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index a82c4ae..cc74b51 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -10,6 +10,7 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/payments/rail" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -81,7 +82,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro return &ledgerClient{ cfg: cfg, conn: conn, - client: ledgerv1.NewLedgerServiceClient(conn), + client: unifiedv1.NewUnifiedGatewayServiceClient(conn), }, nil } diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index a88a88a..3959b2c 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -24,6 +24,7 @@ import ( pmessaging "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" + unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ) @@ -49,7 +50,7 @@ type Service struct { cancel context.CancelFunc publisher *outboxPublisher } - ledgerv1.UnimplementedLedgerServiceServer + unifiedv1.UnimplementedUnifiedGatewayServiceServer } type feesDependency struct { @@ -82,7 +83,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging. func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { - ledgerv1.RegisterLedgerServiceServer(reg, s) + unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) }) } @@ -204,6 +205,7 @@ func (s *Service) startDiscoveryAnnouncer() { announce := discovery.Announcement{ Service: "LEDGER", Operations: []string{"balance.read", "ledger.debit", "ledger.credit"}, + InvokeURI: discovery.DefaultInvokeURI(string(mservice.Ledger)), Version: appversion.Create().Short(), } s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.Ledger), announce) diff --git a/api/notification/go.mod b/api/notification/go.mod index 3647605..65e7f9a 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -8,7 +8,7 @@ require ( 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 + github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/sendgrid/sendgrid-go v3.16.1+incompatible github.com/tech/sendico/pkg v0.1.0 github.com/xhit/go-simple-mail/v2 v2.16.0 diff --git a/api/notification/go.sum b/api/notification/go.sum index 5d8318c..cfff254 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -2,8 +2,8 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -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/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.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.3.0 h1:Lgj31fWThQ6hdDHO0RPxQfy/D7d8K+aqWsBa+IGTxQk= @@ -105,8 +105,8 @@ github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= -github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -189,6 +189,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= diff --git a/api/payments/orchestrator/config.yml b/api/payments/orchestrator/config.yml index 7d6b339..5cd5fa3 100644 --- a/api/payments/orchestrator/config.yml +++ b/api/payments/orchestrator/config.yml @@ -51,6 +51,12 @@ gateway: call_timeout_seconds: 3 insecure: true +payment_gateway: + address: "sendico_tgsettle_gateway:50080" + dial_timeout_seconds: 5 + call_timeout_seconds: 3 + insecure: true + mntx: address: "sendico_mntx_gateway:50075" dial_timeout_seconds: 5 diff --git a/api/payments/orchestrator/internal/server/internal/builders.go b/api/payments/orchestrator/internal/server/internal/builders.go index b2f8858..636dde8 100644 --- a/api/payments/orchestrator/internal/server/internal/builders.go +++ b/api/payments/orchestrator/internal/server/internal/builders.go @@ -4,7 +4,6 @@ import ( "strings" chainclient "github.com/tech/sendico/gateway/chain/client" - mntxclient "github.com/tech/sendico/gateway/mntx/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/discovery" @@ -48,15 +47,15 @@ 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 { +func buildGatewayRegistry(logger mlogger.Logger, src []gatewayInstanceConfig, registry *discovery.Registry) orchestrator.GatewayRegistry { static := buildGatewayInstances(logger, src) - staticRegistry := orchestrator.NewGatewayRegistry(logger, mntxClient, static) + staticRegistry := orchestrator.NewGatewayRegistry(logger, 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 { +func buildRailGateways(chainClient chainclient.Client, paymentGatewayClient chainclient.Client, src []gatewayInstanceConfig) map[string]rail.RailGateway { + if len(src) == 0 || (chainClient == nil && paymentGatewayClient == nil) { return nil } instances := buildGatewayInstances(nil, src) @@ -68,9 +67,6 @@ func buildRailGateways(chainClient chainclient.Client, src []gatewayInstanceConf if inst == nil || !inst.IsEnabled { continue } - if inst.Rail != model.RailCrypto { - continue - } cfg := chainclient.RailGatewayConfig{ Rail: string(inst.Rail), Network: inst.Network, @@ -82,7 +78,18 @@ func buildRailGateways(chainClient chainclient.Client, src []gatewayInstanceConf RequiresObserveConfirm: inst.Capabilities.RequiresObserveConfirm, }, } - result[inst.ID] = chainclient.NewRailGateway(chainClient, cfg) + switch inst.Rail { + case model.RailCrypto: + if chainClient == nil { + continue + } + result[inst.ID] = chainclient.NewRailGateway(chainClient, cfg) + case model.RailProviderSettlement: + if paymentGatewayClient == nil { + continue + } + result[inst.ID] = orchestrator.NewProviderSettlementGateway(paymentGatewayClient, cfg) + } } if len(result) == 0 { return nil diff --git a/api/payments/orchestrator/internal/server/internal/clients.go b/api/payments/orchestrator/internal/server/internal/clients.go index 96c122e..afd8652 100644 --- a/api/payments/orchestrator/internal/server/internal/clients.go +++ b/api/payments/orchestrator/internal/server/internal/clients.go @@ -85,6 +85,29 @@ func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client { return client } +func (i *Imp) initPaymentGatewayClient(cfg clientConfig) chainclient.Client { + addr := cfg.address() + if addr == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout()) + defer cancel() + + client, err := chainclient.New(ctx, chainclient.Config{ + Address: addr, + DialTimeout: cfg.dialTimeout(), + CallTimeout: cfg.callTimeout(), + Insecure: cfg.InsecureTransport, + }) + if err != nil { + i.logger.Warn("failed to connect to payment gateway service", zap.String("address", addr), zap.Error(err)) + return nil + } + i.logger.Info("connected to payment gateway service", zap.String("address", addr)) + return client +} + func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client { addr := cfg.address() if addr == "" { @@ -138,6 +161,9 @@ func (i *Imp) closeClients() { if i.gatewayClient != nil { _ = i.gatewayClient.Close() } + if i.paymentGatewayClient != nil { + _ = i.paymentGatewayClient.Close() + } if i.mntxClient != nil { _ = i.mntxClient.Close() } diff --git a/api/payments/orchestrator/internal/server/internal/config.go b/api/payments/orchestrator/internal/server/internal/config.go index 8ae52aa..9ee1f0a 100644 --- a/api/payments/orchestrator/internal/server/internal/config.go +++ b/api/payments/orchestrator/internal/server/internal/config.go @@ -16,6 +16,7 @@ type config struct { Fees clientConfig `yaml:"fees"` Ledger clientConfig `yaml:"ledger"` Gateway clientConfig `yaml:"gateway"` + PaymentGateway clientConfig `yaml:"payment_gateway"` Mntx clientConfig `yaml:"mntx"` Oracle clientConfig `yaml:"oracle"` CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"` diff --git a/api/payments/orchestrator/internal/server/internal/dependencies.go b/api/payments/orchestrator/internal/server/internal/dependencies.go index ae82f9d..37259c9 100644 --- a/api/payments/orchestrator/internal/server/internal/dependencies.go +++ b/api/payments/orchestrator/internal/server/internal/dependencies.go @@ -10,11 +10,12 @@ import ( ) type orchestratorDeps struct { - feesClient feesv1.FeeEngineClient - ledgerClient ledgerclient.Client - gatewayClient chainclient.Client - mntxClient mntxclient.Client - oracleClient oracleclient.Client + feesClient feesv1.FeeEngineClient + ledgerClient ledgerclient.Client + gatewayClient chainclient.Client + paymentGatewayClient chainclient.Client + mntxClient mntxclient.Client + oracleClient oracleclient.Client } func (i *Imp) initDependencies(cfg *config) *orchestratorDeps { @@ -35,6 +36,11 @@ func (i *Imp) initDependencies(cfg *config) *orchestratorDeps { i.gatewayClient = deps.gatewayClient } + deps.paymentGatewayClient = i.initPaymentGatewayClient(cfg.PaymentGateway) + if deps.paymentGatewayClient != nil { + i.paymentGatewayClient = deps.paymentGatewayClient + } + deps.mntxClient = i.initMntxClient(cfg.Mntx) if deps.mntxClient != nil { i.mntxClient = deps.mntxClient @@ -62,7 +68,10 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest if deps.gatewayClient != nil { opts = append(opts, orchestrator.WithChainGatewayClient(deps.gatewayClient)) } - if railGateways := buildRailGateways(deps.gatewayClient, cfg.GatewayInstances); len(railGateways) > 0 { + if deps.paymentGatewayClient != nil { + opts = append(opts, orchestrator.WithProviderSettlementGatewayClient(deps.paymentGatewayClient)) + } + if railGateways := buildRailGateways(deps.gatewayClient, deps.paymentGatewayClient, cfg.GatewayInstances); len(railGateways) > 0 { opts = append(opts, orchestrator.WithRailGateways(railGateways)) } if deps.mntxClient != nil { @@ -77,7 +86,7 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 { opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts)) } - if registry := buildGatewayRegistry(i.logger, deps.mntxClient, cfg.GatewayInstances, i.discoveryReg); registry != nil { + if registry := buildGatewayRegistry(i.logger, cfg.GatewayInstances, i.discoveryReg); registry != nil { opts = append(opts, orchestrator.WithGatewayRegistry(registry)) } return opts diff --git a/api/payments/orchestrator/internal/server/internal/types.go b/api/payments/orchestrator/internal/server/internal/types.go index 612a59b..315b39f 100644 --- a/api/payments/orchestrator/internal/server/internal/types.go +++ b/api/payments/orchestrator/internal/server/internal/types.go @@ -18,15 +18,16 @@ type Imp struct { file string debug bool - config *config - app *grpcapp.App[storage.Repository] - discoveryWatcher *discovery.RegistryWatcher - discoveryReg *discovery.Registry - discoveryAnnouncer *discovery.Announcer - service *orchestrator.Service - feesConn *grpc.ClientConn - ledgerClient ledgerclient.Client - gatewayClient chainclient.Client - mntxClient mntxclient.Client - oracleClient oracleclient.Client + config *config + app *grpcapp.App[storage.Repository] + discoveryWatcher *discovery.RegistryWatcher + discoveryReg *discovery.Registry + discoveryAnnouncer *discovery.Announcer + service *orchestrator.Service + feesConn *grpc.ClientConn + ledgerClient ledgerclient.Client + gatewayClient chainclient.Client + paymentGatewayClient chainclient.Client + mntxClient mntxclient.Client + oracleClient oracleclient.Client } diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go index 95707b0..e4b82db 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go @@ -5,10 +5,10 @@ import ( "strings" paymodel "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" cons "github.com/tech/sendico/pkg/messaging/consumer" paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" np "github.com/tech/sendico/pkg/messaging/notifications/processor" - "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" "go.uber.org/zap" @@ -47,7 +47,8 @@ func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGat if s.storage == nil || s.storage.Payments() == nil { return errStorageUnavailable } - payment, err := s.storage.Payments().GetByPaymentRef(ctx, paymentRef) + store := s.storage.Payments() + payment, err := store.GetByPaymentRef(ctx, paymentRef) if err != nil { return err } @@ -66,8 +67,12 @@ func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGat } payment.Metadata["gateway_confirmation_status"] = string(exec.Status) + updatedPlan := updateExecutionStepsFromGatewayExecution(payment, exec) switch exec.Status { case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified: + if payment.PaymentPlan != nil && updatedPlan && payment.ExecutionPlan != nil && !executionPlanComplete(payment.ExecutionPlan) { + return s.resumePaymentPlan(ctx, store, payment) + } payment.State = paymodel.PaymentStateSettled payment.FailureCode = paymodel.PaymentFailureCodeUnspecified payment.FailureReason = "" @@ -82,13 +87,69 @@ func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGat default: s.logger.Warn("Unhandled gateway confirmation status", zap.String("status", string(exec.Status)), zap.String("payment_ref", paymentRef)) } - if err := s.storage.Payments().Update(ctx, payment); err != nil { + if err := store.Update(ctx, payment); err != nil { return err } s.logger.Info("Payment gateway execution applied", zap.String("payment_ref", paymentRef), zap.String("status", string(exec.Status)), zap.String("service", string(mservice.PaymentGateway))) return nil } +func updateExecutionStepsFromGatewayExecution(payment *paymodel.Payment, exec *model.PaymentGatewayExecution) bool { + if payment == nil || exec == nil || payment.PaymentPlan == nil { + return false + } + requestID := strings.TrimSpace(exec.RequestID) + if requestID == "" { + return false + } + execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) + if execPlan == nil { + return false + } + status := executionStepStatusFromGatewayStatus(exec.Status) + if status == "" { + return false + } + updated := false + for idx, planStep := range payment.PaymentPlan.Steps { + if planStep == nil { + continue + } + if idx >= len(execPlan.Steps) { + continue + } + execStep := execPlan.Steps[idx] + if execStep == nil { + execStep = &paymodel.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)} + execPlan.Steps[idx] = execStep + } + if strings.EqualFold(strings.TrimSpace(execStep.TransferRef), requestID) { + setExecutionStepStatus(execStep, status) + updated = true + continue + } + if execStep.TransferRef == "" && planStep.Rail == paymodel.RailProviderSettlement { + if planStep.Action == paymodel.RailOperationObserveConfirm || planStep.Action == paymodel.RailOperationSend { + execStep.TransferRef = requestID + setExecutionStepStatus(execStep, status) + updated = true + } + } + } + return updated +} + +func executionStepStatusFromGatewayStatus(status model.ConfirmationStatus) string { + switch status { + case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified: + return executionStepStatusConfirmed + case model.ConfirmationStatusRejected, model.ConfirmationStatusTimeout: + return executionStepStatusFailed + default: + return "" + } +} + func (s *Service) Shutdown() { if s == nil { return diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go index fbce083..86a0bae 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go @@ -5,23 +5,19 @@ import ( "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 { +// NewGatewayRegistry aggregates static gateway descriptors. +func NewGatewayRegistry(logger mlogger.Logger, static []*model.GatewayInstanceDescriptor) GatewayRegistry { + if len(static) == 0 { return nil } if logger != nil { @@ -29,7 +25,6 @@ func NewGatewayRegistry(logger mlogger.Logger, mntxClient mntxclient.Client, sta } return &gatewayRegistry{ logger: logger, - mntx: mntxClient, static: cloneGatewayDescriptors(static), } } @@ -47,27 +42,6 @@ func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDes 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) diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index ebebb32..91ef614 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -48,14 +48,15 @@ func (g gatewayDependency) available() bool { } type railGatewayDependency struct { - byID map[string]rail.RailGateway - byRail map[model.Rail][]rail.RailGateway - registry GatewayRegistry - chainClient chainclient.Client + byID map[string]rail.RailGateway + byRail map[model.Rail][]rail.RailGateway + registry GatewayRegistry + chainClient chainclient.Client + providerClient chainclient.Client } func (g railGatewayDependency) available() bool { - return len(g.byID) > 0 || len(g.byRail) > 0 || (g.registry != nil && g.chainClient != nil) + return len(g.byID) > 0 || len(g.byRail) > 0 || (g.registry != nil && (g.chainClient != nil || g.providerClient != nil)) } func (g railGatewayDependency) resolve(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) { @@ -80,7 +81,7 @@ func (g railGatewayDependency) resolve(ctx context.Context, step *model.PaymentS } func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) { - if g.registry == nil || g.chainClient == nil { + if g.registry == nil || (g.chainClient == nil && g.providerClient == nil) { return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail") } items, err := g.registry.List(ctx) @@ -108,7 +109,18 @@ func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.P RequiresObserveConfirm: entry.Capabilities.RequiresObserveConfirm, }, } - return chainclient.NewRailGateway(g.chainClient, cfg), nil + switch entry.Rail { + case model.RailProviderSettlement: + if g.providerClient == nil { + return nil, merrors.InvalidArgument("rail gateway: missing provider settlement client") + } + return NewProviderSettlementGateway(g.providerClient, cfg), nil + default: + if g.chainClient == nil { + return nil, merrors.InvalidArgument("rail gateway: missing gateway client") + } + return chainclient.NewRailGateway(g.chainClient, cfg), nil + } } return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail") } @@ -137,6 +149,14 @@ func (g gatewayRegistryDependency) available() bool { return g.registry != nil } +type providerGatewayDependency struct { + client chainclient.Client +} + +func (p providerGatewayDependency) available() bool { + return p.client != nil +} + // CardGatewayRoute maps a gateway to its funding and fee destinations. type CardGatewayRoute struct { FundingAddress string @@ -179,13 +199,20 @@ func WithChainGatewayClient(client chainclient.Client) Option { } } +// WithProviderSettlementGatewayClient wires the provider settlement gateway client. +func WithProviderSettlementGatewayClient(client chainclient.Client) Option { + return func(s *Service) { + s.deps.providerGateway = providerGatewayDependency{client: client} + } +} + // 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) + s.deps.railGateways = buildRailGatewayDependency(gateways, s.deps.gatewayRegistry, s.deps.gateway.client, s.deps.providerGateway.client) } } @@ -250,6 +277,7 @@ func WithGatewayRegistry(registry GatewayRegistry) Option { s.deps.gatewayRegistry = registry s.deps.railGateways.registry = registry s.deps.railGateways.chainClient = s.deps.gateway.client + s.deps.railGateways.providerClient = s.deps.providerGateway.client if s.deps.planBuilder == nil { s.deps.planBuilder = &defaultPlanBuilder{} } @@ -266,12 +294,13 @@ func WithClock(clock clockpkg.Clock) Option { } } -func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainClient chainclient.Client) railGatewayDependency { +func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainClient chainclient.Client, providerClient chainclient.Client) railGatewayDependency { result := railGatewayDependency{ - byID: map[string]rail.RailGateway{}, - byRail: map[model.Rail][]rail.RailGateway{}, - registry: registry, - chainClient: chainClient, + byID: map[string]rail.RailGateway{}, + byRail: map[model.Rail][]rail.RailGateway{}, + registry: registry, + chainClient: chainClient, + providerClient: providerClient, } if len(gateways) == 0 { return result 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 index c814edd..ecf226a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go @@ -64,7 +64,7 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { deps: serviceDependencies{ railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{ "crypto-default": railGateway, - }, nil, nil), + }, nil, nil, nil), ledger: ledgerDependency{ client: ledgerFake, internal: ledgerFake, diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go index 8da432e..ae40ee9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go @@ -100,6 +100,33 @@ func (p *paymentExecutor) executeSendStep(ctx context.Context, payment *model.Pa ensureExecutionRefs(payment).CardPayoutRef = ref setExecutionStepStatus(execStep, executionStepStatusSubmitted) return true, nil + case model.RailProviderSettlement: + amount, err := requireMoney(cloneMoney(step.Amount), "provider settlement amount") + if err != nil { + return false, err + } + if !p.deps.railGateways.available() { + return false, merrors.Internal("rail gateway unavailable") + } + req, err := p.buildProviderSettlementTransferRequest(payment, step, amount, quote, idx) + 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 == "" { + execStep.TransferRef = strings.TrimSpace(req.IdempotencyKey) + } + linkProviderSettlementObservation(payment, execStep.TransferRef) + setExecutionStepStatus(execStep, executionStepStatusSubmitted) + return true, nil case model.RailFiatOnRamp: return false, merrors.InvalidArgument("payment plan: fiat on-ramp execution not implemented") default: diff --git a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go new file mode 100644 index 0000000..c5f002f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go @@ -0,0 +1,113 @@ +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" +) + +const ( + providerSettlementMetaPaymentIntentID = "payment_intent_id" + providerSettlementMetaQuoteRef = "quote_ref" + providerSettlementMetaTargetChatID = "target_chat_id" + providerSettlementMetaOutgoingLeg = "outgoing_leg" +) + +func (p *paymentExecutor) buildProviderSettlementTransferRequest(payment *model.Payment, step *model.PaymentStep, amount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote, idx int) (rail.TransferRequest, error) { + if payment == nil || step == nil { + return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment and step are required") + } + if amount == nil { + return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: amount is required") + } + requestID := planStepIdempotencyKey(payment, idx, step) + if requestID == "" { + return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: idempotency key is required") + } + paymentRef := strings.TrimSpace(payment.PaymentRef) + if paymentRef == "" { + return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment_ref is required") + } + metadata := cloneMetadata(payment.Metadata) + if metadata == nil { + metadata = map[string]string{} + } + metadata[providerSettlementMetaPaymentIntentID] = paymentRef + if quoteRef := paymentGatewayQuoteRef(payment, quote); quoteRef != "" { + metadata[providerSettlementMetaQuoteRef] = quoteRef + } + if chatID := paymentGatewayTargetChatID(payment); chatID != "" { + metadata[providerSettlementMetaTargetChatID] = chatID + } + if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" { + metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(string(step.Rail))) + } + return rail.TransferRequest{ + OrganizationRef: payment.OrganizationRef.Hex(), + Currency: strings.TrimSpace(amount.GetCurrency()), + Amount: strings.TrimSpace(amount.GetAmount()), + IdempotencyKey: requestID, + Metadata: metadata, + ClientReference: paymentRef, + }, nil +} + +func paymentGatewayQuoteRef(payment *model.Payment, quote *orchestratorv1.PaymentQuote) string { + if quote != nil { + if ref := strings.TrimSpace(quote.GetQuoteRef()); ref != "" { + return ref + } + } + if payment != nil && payment.LastQuote != nil { + return strings.TrimSpace(payment.LastQuote.QuoteRef) + } + return "" +} + +func paymentGatewayTargetChatID(payment *model.Payment) string { + if payment == nil { + return "" + } + if payment.Intent.Attributes != nil { + if chatID := strings.TrimSpace(payment.Intent.Attributes["target_chat_id"]); chatID != "" { + return chatID + } + } + if payment.Metadata != nil { + return strings.TrimSpace(payment.Metadata["target_chat_id"]) + } + return "" +} + +func linkProviderSettlementObservation(payment *model.Payment, requestID string) { + if payment == nil || payment.PaymentPlan == nil || payment.ExecutionPlan == nil { + return + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return + } + for idx, planStep := range payment.PaymentPlan.Steps { + if planStep == nil { + continue + } + if planStep.Rail != model.RailProviderSettlement || planStep.Action != model.RailOperationObserveConfirm { + continue + } + if idx >= len(payment.ExecutionPlan.Steps) { + continue + } + execStep := payment.ExecutionPlan.Steps[idx] + if execStep == nil { + execStep = &model.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)} + payment.ExecutionPlan.Steps[idx] = execStep + } + if execStep.TransferRef == "" { + execStep.TransferRef = requestID + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go new file mode 100644 index 0000000..40ef9d9 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go @@ -0,0 +1,164 @@ +package orchestrator + +import ( + "context" + "strings" + + chainclient "github.com/tech/sendico/gateway/chain/client" + "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" +) + +type providerSettlementGateway struct { + client chainclient.Client + rail string + network string + capabilities rail.RailCapabilities +} + +func NewProviderSettlementGateway(client chainclient.Client, cfg chainclient.RailGatewayConfig) rail.RailGateway { + railName := strings.ToUpper(strings.TrimSpace(cfg.Rail)) + if railName == "" { + railName = "PROVIDER_SETTLEMENT" + } + return &providerSettlementGateway{ + client: client, + rail: railName, + network: strings.ToUpper(strings.TrimSpace(cfg.Network)), + capabilities: cfg.Capabilities, + } +} + +func (g *providerSettlementGateway) Rail() string { + return g.rail +} + +func (g *providerSettlementGateway) Network() string { + return g.network +} + +func (g *providerSettlementGateway) Capabilities() rail.RailCapabilities { + return g.capabilities +} + +func (g *providerSettlementGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { + if g.client == nil { + return rail.RailResult{}, merrors.Internal("provider settlement gateway: client is required") + } + idempotencyKey := strings.TrimSpace(req.IdempotencyKey) + if idempotencyKey == "" { + return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: idempotency_key is required") + } + currency := strings.TrimSpace(req.Currency) + amount := strings.TrimSpace(req.Amount) + if currency == "" || amount == "" { + return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: amount is required") + } + metadata := cloneMetadata(req.Metadata) + if metadata == nil { + metadata = map[string]string{} + } + if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { + if ref := strings.TrimSpace(req.ClientReference); ref != "" { + metadata[providerSettlementMetaPaymentIntentID] = ref + } + } + if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { + return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: payment_intent_id is required") + } + if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" && g.rail != "" { + metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(g.rail)) + } + submitReq := &chainv1.SubmitTransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: strings.TrimSpace(req.OrganizationRef), + SourceWalletRef: strings.TrimSpace(req.FromAccountID), + Amount: &moneyv1.Money{ + Currency: currency, + Amount: amount, + }, + Metadata: metadata, + ClientReference: strings.TrimSpace(req.ClientReference), + } + if dest := buildProviderSettlementDestination(req); dest != nil { + submitReq.Destination = dest + } + resp, err := g.client.SubmitTransfer(ctx, submitReq) + if err != nil { + return rail.RailResult{}, err + } + if resp == nil || resp.GetTransfer() == nil { + return rail.RailResult{}, merrors.Internal("provider settlement gateway: missing transfer response") + } + transfer := resp.GetTransfer() + return rail.RailResult{ + ReferenceID: strings.TrimSpace(transfer.GetTransferRef()), + Status: providerSettlementStatusFromTransfer(transfer.GetStatus()), + FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), + }, nil +} + +func (g *providerSettlementGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { + if g.client == nil { + return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: client is required") + } + ref := strings.TrimSpace(referenceID) + if ref == "" { + return rail.ObserveResult{}, merrors.InvalidArgument("provider settlement 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("provider settlement gateway: missing transfer response") + } + transfer := resp.GetTransfer() + return rail.ObserveResult{ + ReferenceID: ref, + Status: providerSettlementStatusFromTransfer(transfer.GetStatus()), + FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), + }, nil +} + +func buildProviderSettlementDestination(req rail.TransferRequest) *chainv1.TransferDestination { + destRef := strings.TrimSpace(req.ToAccountID) + memo := strings.TrimSpace(req.DestinationMemo) + if destRef == "" && memo == "" { + return nil + } + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef}, + Memo: memo, + } +} + +func providerSettlementStatusFromTransfer(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 + default: + return rail.TransferStatusPending + } +} + +func railMoneyFromProto(src *moneyv1.Money) *rail.Money { + if src == nil { + return nil + } + currency := strings.TrimSpace(src.GetCurrency()) + amount := strings.TrimSpace(src.GetAmount()) + if currency == "" || amount == "" { + return nil + } + return &rail.Money{ + Amount: amount, + Currency: currency, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index f278ab4..93dbb15 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -50,6 +50,7 @@ type serviceDependencies struct { ledger ledgerDependency gateway gatewayDependency railGateways railGatewayDependency + providerGateway providerGatewayDependency oracle oracleDependency mntx mntxDependency gatewayRegistry GatewayRegistry diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index ead5954..a0d98ff 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -125,7 +125,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) { return rail.RailResult{}, errors.New("chain failure") }, }, - }, nil, nil), + }, nil, nil, nil), gatewayRegistry: &stubGatewayRegistry{ items: []*model.GatewayInstanceDescriptor{ { diff --git a/api/proto/gateway/chain/v1/chain.proto b/api/proto/gateway/chain/v1/chain.proto index 932ee1d..3f7efb1 100644 --- a/api/proto/gateway/chain/v1/chain.proto +++ b/api/proto/gateway/chain/v1/chain.proto @@ -231,19 +231,3 @@ message TransferStatusChangedEvent { Transfer transfer = 1; string reason = 2; } - -service ChainGatewayService { - rpc CreateManagedWallet(CreateManagedWalletRequest) returns (CreateManagedWalletResponse); - rpc GetManagedWallet(GetManagedWalletRequest) returns (GetManagedWalletResponse); - rpc ListManagedWallets(ListManagedWalletsRequest) returns (ListManagedWalletsResponse); - - rpc GetWalletBalance(GetWalletBalanceRequest) returns (GetWalletBalanceResponse); - - rpc SubmitTransfer(SubmitTransferRequest) returns (SubmitTransferResponse); - rpc GetTransfer(GetTransferRequest) returns (GetTransferResponse); - rpc ListTransfers(ListTransfersRequest) returns (ListTransfersResponse); - - rpc EstimateTransferFee(EstimateTransferFeeRequest) returns (EstimateTransferFeeResponse); - rpc ComputeGasTopUp(ComputeGasTopUpRequest) returns (ComputeGasTopUpResponse); - rpc EnsureGasTopUp(EnsureGasTopUpRequest) returns (EnsureGasTopUpResponse); -} diff --git a/api/proto/gateway/mntx/v1/mntx.proto b/api/proto/gateway/mntx/v1/mntx.proto index 3cbd688..7ed8f65 100644 --- a/api/proto/gateway/mntx/v1/mntx.proto +++ b/api/proto/gateway/mntx/v1/mntx.proto @@ -164,11 +164,3 @@ message CardTokenizeResponse { string error_code = 8; string error_message = 9; } - -service MntxGatewayService { - 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/gateway/unified/v1/gateway.proto b/api/proto/gateway/unified/v1/gateway.proto new file mode 100644 index 0000000..8943a8c --- /dev/null +++ b/api/proto/gateway/unified/v1/gateway.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; + +package gateway.unified.v1; + +option go_package = "github.com/tech/sendico/pkg/proto/gateway/unified/v1;unifiedv1"; + +import "gateway/chain/v1/chain.proto"; +import "gateway/mntx/v1/mntx.proto"; +import "ledger/v1/ledger.proto"; + +// UnifiedGatewayService exposes gateway and ledger operations via a single interface. +service UnifiedGatewayService { + // Chain gateway operations. + rpc CreateManagedWallet(chain.gateway.v1.CreateManagedWalletRequest) returns (chain.gateway.v1.CreateManagedWalletResponse); + rpc GetManagedWallet(chain.gateway.v1.GetManagedWalletRequest) returns (chain.gateway.v1.GetManagedWalletResponse); + rpc ListManagedWallets(chain.gateway.v1.ListManagedWalletsRequest) returns (chain.gateway.v1.ListManagedWalletsResponse); + rpc GetWalletBalance(chain.gateway.v1.GetWalletBalanceRequest) returns (chain.gateway.v1.GetWalletBalanceResponse); + + rpc SubmitTransfer(chain.gateway.v1.SubmitTransferRequest) returns (chain.gateway.v1.SubmitTransferResponse); + rpc GetTransfer(chain.gateway.v1.GetTransferRequest) returns (chain.gateway.v1.GetTransferResponse); + rpc ListTransfers(chain.gateway.v1.ListTransfersRequest) returns (chain.gateway.v1.ListTransfersResponse); + + rpc EstimateTransferFee(chain.gateway.v1.EstimateTransferFeeRequest) returns (chain.gateway.v1.EstimateTransferFeeResponse); + rpc ComputeGasTopUp(chain.gateway.v1.ComputeGasTopUpRequest) returns (chain.gateway.v1.ComputeGasTopUpResponse); + rpc EnsureGasTopUp(chain.gateway.v1.EnsureGasTopUpRequest) returns (chain.gateway.v1.EnsureGasTopUpResponse); + + // Card payout gateway operations. + rpc CreateCardPayout(mntx.gateway.v1.CardPayoutRequest) returns (mntx.gateway.v1.CardPayoutResponse); + rpc GetCardPayoutStatus(mntx.gateway.v1.GetCardPayoutStatusRequest) returns (mntx.gateway.v1.GetCardPayoutStatusResponse); + rpc CreateCardTokenPayout(mntx.gateway.v1.CardTokenPayoutRequest) returns (mntx.gateway.v1.CardTokenPayoutResponse); + rpc CreateCardToken(mntx.gateway.v1.CardTokenizeRequest) returns (mntx.gateway.v1.CardTokenizeResponse); + rpc ListGatewayInstances(mntx.gateway.v1.ListGatewayInstancesRequest) returns (mntx.gateway.v1.ListGatewayInstancesResponse); + + // Ledger operations. + rpc CreateAccount(ledger.v1.CreateAccountRequest) returns (ledger.v1.CreateAccountResponse); + rpc ListAccounts(ledger.v1.ListAccountsRequest) returns (ledger.v1.ListAccountsResponse); + rpc PostCreditWithCharges(ledger.v1.PostCreditRequest) returns (ledger.v1.PostResponse); + rpc PostDebitWithCharges(ledger.v1.PostDebitRequest) returns (ledger.v1.PostResponse); + rpc TransferInternal(ledger.v1.TransferRequest) returns (ledger.v1.PostResponse); + rpc ApplyFXWithCharges(ledger.v1.FXRequest) returns (ledger.v1.PostResponse); + + rpc GetBalance(ledger.v1.GetBalanceRequest) returns (ledger.v1.BalanceResponse); + rpc GetJournalEntry(ledger.v1.GetEntryRequest) returns (ledger.v1.JournalEntryResponse); + rpc GetStatement(ledger.v1.GetStatementRequest) returns (ledger.v1.StatementResponse); +} diff --git a/api/proto/ledger/v1/ledger.proto b/api/proto/ledger/v1/ledger.proto index 3bd38b4..e6fe38a 100644 --- a/api/proto/ledger/v1/ledger.proto +++ b/api/proto/ledger/v1/ledger.proto @@ -66,22 +66,6 @@ message PostingLine { // ===== Requests/Responses ===== -service LedgerService { - rpc CreateAccount (CreateAccountRequest) returns (CreateAccountResponse); - - rpc PostCreditWithCharges (PostCreditRequest) returns (PostResponse); - rpc PostDebitWithCharges (PostDebitRequest) returns (PostResponse); - rpc TransferInternal (TransferRequest) returns (PostResponse); - rpc ApplyFXWithCharges (FXRequest) returns (PostResponse); - - rpc GetBalance (GetBalanceRequest) returns (BalanceResponse); - rpc GetJournalEntry (GetEntryRequest) returns (JournalEntryResponse); - rpc GetStatement (GetStatementRequest) returns (StatementResponse); - - // Lists ledger accounts for an organization. - rpc ListAccounts (ListAccountsRequest) returns (ListAccountsResponse); -} - message CreateAccountRequest { string organization_ref = 1; string account_code = 2; diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index 3f62b4c..9d21f83 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -1,19 +1,22 @@ package srequest import ( + "strings" + "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" ) type PaymentIntent struct { - Kind PaymentKind `json:"kind,omitempty"` - Source *Endpoint `json:"source,omitempty"` - Destination *Endpoint `json:"destination,omitempty"` - Amount *model.Money `json:"amount,omitempty"` - FX *FXIntent `json:"fx,omitempty"` - SettlementMode SettlementMode `json:"settlement_mode,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` - Customer *Customer `json:"customer,omitempty"` + Kind PaymentKind `json:"kind,omitempty"` + Source *Endpoint `json:"source,omitempty"` + Destination *Endpoint `json:"destination,omitempty"` + Amount *model.Money `json:"amount,omitempty"` + FX *FXIntent `json:"fx,omitempty"` + SettlementMode SettlementMode `json:"settlement_mode,omitempty"` + SettlementCurrency string `json:"settlement_currency,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Customer *Customer `json:"customer,omitempty"` } type AssetResolverStub struct{} @@ -51,5 +54,11 @@ func (p *PaymentIntent) Validate() error { } } + if strings.TrimSpace(p.SettlementCurrency) != "" { + if err := ValidateCurrency(p.SettlementCurrency, &AssetResolverStub{}); err != nil { + return err + } + } + return nil } diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 80089ad..f0e6718 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -26,6 +26,10 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn if err != nil { return nil, err } + settlementCurrency := strings.TrimSpace(intent.SettlementCurrency) + if settlementCurrency == "" { + settlementCurrency = resolveSettlementCurrency(intent) + } source, err := mapPaymentEndpoint(intent.Source, "source") if err != nil { @@ -42,18 +46,66 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn } return &orchestratorv1.PaymentIntent{ - Kind: kind, - Source: source, - Destination: destination, - Amount: mapMoney(intent.Amount), - RequiresFx: fx != nil, - Fx: fx, - SettlementMode: settlementMode, - Attributes: copyStringMap(intent.Attributes), - Customer: mapCustomer(intent.Customer), + Kind: kind, + Source: source, + Destination: destination, + Amount: mapMoney(intent.Amount), + RequiresFx: fx != nil, + Fx: fx, + SettlementMode: settlementMode, + SettlementCurrency: settlementCurrency, + Attributes: copyStringMap(intent.Attributes), + Customer: mapCustomer(intent.Customer), }, nil } +func resolveSettlementCurrency(intent *srequest.PaymentIntent) string { + if intent == nil { + return "" + } + + fx := intent.FX + if fx != nil && fx.Pair != nil { + base := strings.TrimSpace(fx.Pair.Base) + quote := strings.TrimSpace(fx.Pair.Quote) + switch strings.TrimSpace(string(fx.Side)) { + case string(srequest.FXSideBuyBaseSellQuote): + if base != "" { + return base + } + case string(srequest.FXSideSellBaseBuyQuote): + if quote != "" { + return quote + } + } + if intent.Amount != nil { + amountCurrency := strings.TrimSpace(intent.Amount.Currency) + if amountCurrency != "" { + switch { + case strings.EqualFold(amountCurrency, base) && quote != "": + return quote + case strings.EqualFold(amountCurrency, quote) && base != "": + return base + default: + return amountCurrency + } + } + } + if quote != "" { + return quote + } + if base != "" { + return base + } + } + + if intent.Amount != nil { + return strings.TrimSpace(intent.Amount.Currency) + } + + return "" +} + func mapPaymentEndpoint(endpoint *srequest.Endpoint, field string) (*orchestratorv1.PaymentEndpoint, error) { if endpoint == nil { return nil, nil diff --git a/ci/scripts/proto/generate.sh b/ci/scripts/proto/generate.sh index 7a3e19a..25c0b51 100755 --- a/ci/scripts/proto/generate.sh +++ b/ci/scripts/proto/generate.sh @@ -116,6 +116,12 @@ if [ -f "${PROTO_DIR}/gateway/mntx/v1/mntx.proto" ]; then generate_go_with_grpc "${PROTO_DIR}/gateway/mntx/v1/mntx.proto" fi +if [ -f "${PROTO_DIR}/gateway/unified/v1/gateway.proto" ]; then + info "Compiling unified gateway protos" + clean_pb_files "./pkg/proto/gateway/unified" + generate_go_with_grpc "${PROTO_DIR}/gateway/unified/v1/gateway.proto" +fi + if [ -f "${PROTO_DIR}/payments/orchestrator/v1/orchestrator.proto" ]; then info "Compiling payments orchestrator protos" clean_pb_files "./pkg/proto/payments/orchestrator" diff --git a/frontend/pshared/lib/data/dto/payment/intent/payment.dart b/frontend/pshared/lib/data/dto/payment/intent/payment.dart index 34cf653..a1da3e2 100644 --- a/frontend/pshared/lib/data/dto/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/intent/payment.dart @@ -20,6 +20,9 @@ class PaymentIntentDTO { @JsonKey(name: 'settlement_mode') final String? settlementMode; + @JsonKey(name: 'settlement_currency') + final String? settlementCurrency; + final Map? attributes; final CustomerDTO? customer; @@ -30,6 +33,7 @@ class PaymentIntentDTO { this.amount, this.fx, this.settlementMode, + this.settlementCurrency, this.attributes, this.customer, }); diff --git a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart index 06286f3..78c46d5 100644 --- a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart @@ -15,6 +15,7 @@ extension PaymentIntentMapper on PaymentIntent { amount: amount?.toDTO(), fx: fx?.toDTO(), settlementMode: settlementModeToValue(settlementMode), + settlementCurrency: settlementCurrency, attributes: attributes, customer: customer?.toDTO(), ); @@ -28,6 +29,7 @@ extension PaymentIntentDTOMapper on PaymentIntentDTO { amount: amount?.toDomain(), fx: fx?.toDomain(), settlementMode: settlementModeFromValue(settlementMode), + settlementCurrency: settlementCurrency, attributes: attributes, customer: customer?.toDomain(), ); diff --git a/frontend/pshared/lib/models/payment/intent.dart b/frontend/pshared/lib/models/payment/intent.dart index 24278b1..94fd582 100644 --- a/frontend/pshared/lib/models/payment/intent.dart +++ b/frontend/pshared/lib/models/payment/intent.dart @@ -13,6 +13,7 @@ class PaymentIntent { final Money? amount; final FxIntent? fx; final SettlementMode settlementMode; + final String? settlementCurrency; final Map? attributes; final Customer? customer; @@ -23,6 +24,7 @@ class PaymentIntent { this.amount, this.fx, this.settlementMode = SettlementMode.unspecified, + this.settlementCurrency, this.attributes, this.customer, }); diff --git a/frontend/pshared/lib/provider/payment/quotation.dart b/frontend/pshared/lib/provider/payment/quotation.dart index 7141de9..6af30e0 100644 --- a/frontend/pshared/lib/provider/payment/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation.dart @@ -51,25 +51,28 @@ class QuotationProvider extends ChangeNotifier { recipient: recipients.currentObject, method: method, ); + final amount = Money( + amount: payment.amount.toString(), + // TODO: adapt to possible other sources + currency: currencyCodeToString(wallets.selectedWallet!.currency), + ); + final fxIntent = FxIntent( + pair: CurrencyPair( + base: currencyCodeToString(wallets.selectedWallet!.currency), + quote: 'RUB', // TODO: exentd target currencies + ), + side: FxSide.sellBaseBuyQuote, + ); getQuotation(PaymentIntent( kind: PaymentKind.payout, - amount: Money( - amount: payment.amount.toString(), - // TODO: adapt to possible other sources - currency: currencyCodeToString(wallets.selectedWallet!.currency), - ), + amount: amount, destination: method.data, source: ManagedWalletPaymentMethod( managedWalletRef: wallets.selectedWallet!.id, ), - fx: FxIntent( - pair: CurrencyPair( - base: currencyCodeToString(wallets.selectedWallet!.currency), - quote: 'RUB', // TODO: exentd target currencies - ), - side: FxSide.sellBaseBuyQuote, - ), + fx: fxIntent, settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, + settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent), customer: customer, )); } @@ -83,6 +86,30 @@ class QuotationProvider extends ChangeNotifier { Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount); Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount); + String _resolveSettlementCurrency({ + required Money amount, + required FxIntent? fx, + }) { + final pair = fx?.pair; + if (pair != null) { + switch (fx?.side ?? FxSide.unspecified) { + case FxSide.buyBaseSellQuote: + if (pair.base.isNotEmpty) return pair.base; + break; + case FxSide.sellBaseBuyQuote: + if (pair.quote.isNotEmpty) return pair.quote; + break; + case FxSide.unspecified: + break; + } + if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote; + if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base; + if (pair.quote.isNotEmpty) return pair.quote; + if (pair.base.isNotEmpty) return pair.base; + } + return amount.currency; + } + Customer _buildCustomer({ required Recipient? recipient, required PaymentMethod method, -- 2.49.1