unified gateway interfaces

This commit is contained in:
Stephan D
2026-01-04 12:47:43 +01:00
parent 743f683d92
commit 59c83e414a
41 changed files with 927 additions and 186 deletions

View File

@@ -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 ""