From 59c83e414ac6f7d5fcbef04e630b8f2c080184c4 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sun, 4 Jan 2026 12:47:43 +0100 Subject: [PATCH] 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,