refactored payment orchestration

This commit is contained in:
Stephan D
2026-02-03 00:40:46 +01:00
parent 05d998e0f7
commit 5e87e2f2f9
184 changed files with 3920 additions and 2219 deletions

View File

@@ -7,8 +7,8 @@ import (
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
@@ -18,9 +18,9 @@ const tgsettleConnectorID = "tgsettle"
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
return &connectorv1.GetCapabilitiesResponse{
Capabilities: &connectorv1.ConnectorCapabilities{
ConnectorType: tgsettleConnectorID,
Version: "",
SupportedAccountKinds: nil,
ConnectorType: tgsettleConnectorID,
Version: "",
SupportedAccountKinds: nil,
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER},
OperationParams: tgsettleOperationParams(),
},
@@ -64,7 +64,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
}
paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id"))
if paymentIntentID == "" {
paymentIntentID = strings.TrimSpace(reader.String("client_reference"))
paymentIntentID = strings.TrimSpace(reader.String("payment_ref"))
}
if paymentIntentID == "" {
paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID])
@@ -122,7 +122,9 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
Destination: dest,
Amount: normalizedAmount,
Metadata: metadata,
ClientReference: paymentIntentID,
PaymentRef: paymentIntentID,
IntentRef: strings.TrimSpace(op.GetIntentRef()),
OperationRef: strings.TrimSpace(op.GetOperationRef()),
})
if err != nil {
s.logger.Warn("Submit operation transfer failed", append(logFields, zap.Error(err))...)
@@ -239,14 +241,29 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return connectorv1.OperationStatus_OPERATION_PROCESSING
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
return connectorv1.OperationStatus_OPERATION_CANCELLED
case chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED:
fallthrough
default:
return connectorv1.OperationStatus_PENDING
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}

View File

@@ -16,7 +16,6 @@ import (
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"
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
"github.com/tech/sendico/pkg/mlogger"
@@ -188,12 +187,7 @@ func (s *Service) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransfe
return nil, err
}
if existing != nil {
existing, err = s.expirePaymentIfNeeded(ctx, existing)
if err != nil {
s.logger.Warn("Submit transfer status refresh failed", append(logFields, zap.Error(err))...)
return nil, err
}
s.logger.Info("Submit transfer idempotent hit", append(logFields, zap.String("status", string(paymentStatus(existing))))...)
s.logger.Info("Submit transfer idempotent hit", append(logFields, zap.String("status", string(existing.Status)))...)
return &chainv1.SubmitTransferResponse{Transfer: transferFromPayment(existing, req)}, nil
}
if err := s.onIntent(ctx, intent); err != nil {
@@ -225,14 +219,9 @@ func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferReque
return nil, err
}
if existing != nil {
existing, err = s.expirePaymentIfNeeded(ctx, existing)
if err != nil {
s.logger.Warn("Get transfer status refresh failed", append(logFields, zap.Error(err))...)
return nil, err
}
s.logger.Info("Get transfer resolved from execution", append(logFields,
zap.String("payment_intent_id", strings.TrimSpace(existing.PaymentIntentID)),
zap.String("status", string(paymentStatus(existing))),
zap.String("status", string(existing.Status)),
)...)
return &chainv1.GetTransferResponse{Transfer: transferFromPayment(existing, nil)}, nil
}
@@ -274,27 +263,30 @@ func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayInte
return err
}
if existing != nil {
existing, err = s.expirePaymentIfNeeded(ctx, existing)
if err != nil {
return err
}
s.logger.Info("Payment gateway intent already recorded",
zap.String("idempotency_key", confirmReq.RequestID),
zap.String("payment_intent_id", confirmReq.PaymentIntentID),
zap.String("quote_ref", confirmReq.QuoteRef),
zap.String("rail", confirmReq.Rail),
zap.String("status", string(paymentStatus(existing))))
zap.String("status", string(existing.Status)))
return nil
}
record := paymentRecordFromIntent(intent, confirmReq)
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to persist pending payment", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
if err := s.updateTransferStatus(ctx, record); err != nil {
s.logger.Warn("Failed to persist payment record", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
return err
}
if err := s.sendConfirmationRequest(confirmReq); err != nil {
s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
s.markPaymentExpired(ctx, record, time.Now())
// If the confirmation request was not sent, we keep the record in waiting
// (or it can be marked as failed — depending on your semantics).
// Here, failed is chosen to avoid it hanging indefinitely.
record.Status = storagemodel.PaymentStatusFailed
record.UpdatedAt = time.Now()
if e := s.updateTransferStatus(ctx, record); e != nil {
s.logger.Warn("Failed to update payment status change", zap.Error(e), zap.String("idempotency_key", confirmReq.RequestID))
}
return err
}
return nil
@@ -302,65 +294,88 @@ func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayInte
func (s *Service) onConfirmationResult(ctx context.Context, result *model.ConfirmationResult) error {
if result == nil {
s.logger.Warn("Confirmation result rejected", zap.String("reason", "result is nil"))
return merrors.InvalidArgument("confirmation result is nil", "result")
}
requestID := strings.TrimSpace(result.RequestID)
if requestID == "" {
s.logger.Warn("Confirmation result rejected", zap.String("reason", "request_id is required"))
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
}
record, err := s.loadPayment(ctx, requestID)
if err != nil {
s.logger.Warn("Confirmation result lookup failed", zap.Error(err), zap.String("request_id", requestID))
return err
}
if record == nil {
s.logger.Warn("Confirmation result ignored: payment not found", zap.String("request_id", requestID))
return nil
}
// Store raw reply for audit/debug purposes. This does NOT affect payment state.
if result.RawReply != nil && s.repo != nil && s.repo.TelegramConfirmations() != nil {
if err := s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{
if e := s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{
RequestID: requestID,
PaymentIntentID: record.PaymentIntentID,
QuoteRef: record.QuoteRef,
RawReply: result.RawReply,
}); err != nil {
s.logger.Warn("Failed to store telegram confirmation", zap.Error(err), zap.String("request_id", requestID))
} else {
s.logger.Info("Stored telegram confirmation", zap.String("request_id", requestID),
zap.String("payment_intent_id", record.PaymentIntentID),
zap.String("reply_text", result.RawReply.Text), zap.String("reply_user_id", result.RawReply.FromUserID),
zap.String("reply_user", result.RawReply.FromUsername))
}); e != nil {
s.logger.Warn("Failed to store confirmation error", zap.Error(e),
zap.String("request_id", requestID),
zap.String("status", string(result.Status)))
}
}
nextStatus := paymentStatusFromResult(result)
currentStatus := paymentStatus(record)
if currentStatus == storagemodel.PaymentStatusExecuted || currentStatus == storagemodel.PaymentStatusExpired {
s.logger.Info("Confirmation result ignored: payment already finalized",
zap.String("request_id", requestID),
zap.String("status", string(currentStatus)))
// If the payment is already finalized — ignore the result.
switch record.Status {
case storagemodel.PaymentStatusSuccess,
storagemodel.PaymentStatusFailed,
storagemodel.PaymentStatusCancelled:
return nil
}
s.applyPaymentResult(record, nextStatus, result)
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to persist payment status", zap.Error(err), zap.String("request_id", requestID))
now := time.Now()
switch result.Status {
// FINAL: confirmation succeeded
case model.ConfirmationStatusConfirmed:
record.Status = storagemodel.PaymentStatusSuccess
record.ExecutedMoney = result.Money
if record.ExecutedAt.IsZero() {
record.ExecutedAt = now
}
record.UpdatedAt = now
// FINAL: confirmation rejected or timed out
case model.ConfirmationStatusRejected,
model.ConfirmationStatusTimeout:
record.Status = storagemodel.PaymentStatusFailed
record.UpdatedAt = now
// NOT FINAL: do absolutely nothing
case model.ConfirmationStatusClarified:
s.logger.Debug("Confirmation clarified — no state change",
zap.String("request_id", requestID))
return nil
default:
s.logger.Debug("Non-final confirmation status — ignored",
zap.String("request_id", requestID),
zap.String("status", string(result.Status)))
return nil
}
// The ONLY place where state is persisted and rail event may be emitted
if err := s.updateTransferStatus(ctx, record); err != nil {
return err
}
intent := intentFromPayment(record)
s.publishExecution(intent, result)
s.publishTelegramReaction(result)
return nil
}
func (s *Service) buildConfirmationRequest(intent *model.PaymentGatewayIntent) (*model.ConfirmationRequest, error) {
targetChatID := strings.TrimSpace(intent.TargetChatID)
if targetChatID == "" {
targetChatID = s.chatID
}
targetChatID := s.chatID
if targetChatID == "" {
return nil, merrors.InvalidArgument("target_chat_id is required", "target_chat_id")
}
@@ -414,38 +429,6 @@ func (s *Service) sendConfirmationRequest(request *model.ConfirmationRequest) er
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),
zap.String("idempotency_key", intent.IdempotencyKey),
zap.String("payment_intent_id", intent.PaymentIntentID),
zap.String("quote_ref", intent.QuoteRef),
zap.String("status", string(result.Status)))
return
}
s.logger.Info("Published gateway execution result",
zap.String("request_id", result.RequestID),
zap.String("idempotency_key", intent.IdempotencyKey),
zap.String("payment_intent_id", intent.PaymentIntentID),
zap.String("quote_ref", intent.QuoteRef),
zap.String("status", string(result.Status)))
}
func (s *Service) publishTelegramReaction(result *model.ConfirmationResult) {
if s == nil || s.producer == nil || result == nil || result.RawReply == nil {
return
@@ -490,46 +473,6 @@ func (s *Service) loadPayment(ctx context.Context, requestID string) (*storagemo
return s.repo.Payments().FindByIdempotencyKey(ctx, requestID)
}
func (s *Service) expirePaymentIfNeeded(ctx context.Context, record *storagemodel.PaymentRecord) (*storagemodel.PaymentRecord, error) {
if record == nil {
return nil, nil
}
status := paymentStatus(record)
if status != storagemodel.PaymentStatusPending {
return record, nil
}
if record.ExpiresAt.IsZero() {
return record, nil
}
if time.Now().Before(record.ExpiresAt) {
return record, nil
}
record.Status = storagemodel.PaymentStatusExpired
if record.ExpiredAt.IsZero() {
record.ExpiredAt = time.Now()
}
if s != nil && s.repo != nil && s.repo.Payments() != nil {
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
return record, err
}
}
return record, nil
}
func (s *Service) markPaymentExpired(ctx context.Context, record *storagemodel.PaymentRecord, when time.Time) {
if record == nil || s == nil || s.repo == nil || s.repo.Payments() == nil {
return
}
if when.IsZero() {
when = time.Now()
}
record.Status = storagemodel.PaymentStatusExpired
record.ExpiredAt = when
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to mark payment as expired", zap.Error(err), zap.String("request_id", record.IdempotencyKey))
}
}
func (s *Service) startAnnouncer() {
if s == nil || s.producer == nil {
return
@@ -557,78 +500,37 @@ func normalizeIntent(intent *model.PaymentGatewayIntent) *model.PaymentGatewayIn
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)
}
cp.IntentRef = strings.TrimSpace(cp.IntentRef)
cp.OperationRef = strings.TrimSpace(cp.OperationRef)
return &cp
}
func paymentStatus(record *storagemodel.PaymentRecord) storagemodel.PaymentStatus {
if record == nil {
return storagemodel.PaymentStatusPending
}
if record.Status != "" {
return record.Status
}
if record.ExecutedMoney != nil || !record.ExecutedAt.IsZero() {
return storagemodel.PaymentStatusExecuted
}
return storagemodel.PaymentStatusPending
}
func paymentStatusFromResult(result *model.ConfirmationResult) storagemodel.PaymentStatus {
if result == nil {
return storagemodel.PaymentStatusPending
}
switch result.Status {
case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified:
return storagemodel.PaymentStatusExecuted
case model.ConfirmationStatusTimeout, model.ConfirmationStatusRejected:
return storagemodel.PaymentStatusExpired
default:
return storagemodel.PaymentStatusPending
}
}
func (s *Service) applyPaymentResult(record *storagemodel.PaymentRecord, status storagemodel.PaymentStatus, result *model.ConfirmationResult) {
if record == nil {
return
}
record.Status = status
switch status {
case storagemodel.PaymentStatusExecuted:
record.ExecutedMoney = result.Money
if record.ExecutedAt.IsZero() {
record.ExecutedAt = time.Now()
}
case storagemodel.PaymentStatusExpired:
if record.ExpiredAt.IsZero() {
record.ExpiredAt = time.Now()
}
}
}
func paymentRecordFromIntent(intent *model.PaymentGatewayIntent, confirmReq *model.ConfirmationRequest) *storagemodel.PaymentRecord {
record := &storagemodel.PaymentRecord{
Status: storagemodel.PaymentStatusPending,
Status: storagemodel.PaymentStatusWaiting,
}
if intent != nil {
record.IdempotencyKey = strings.TrimSpace(intent.IdempotencyKey)
record.PaymentIntentID = strings.TrimSpace(intent.PaymentIntentID)
record.QuoteRef = strings.TrimSpace(intent.QuoteRef)
record.OutgoingLeg = strings.TrimSpace(intent.OutgoingLeg)
record.TargetChatID = strings.TrimSpace(intent.TargetChatID)
record.RequestedMoney = intent.RequestedMoney
record.IntentRef = intent.IntentRef
record.OperationRef = intent.OperationRef
}
if confirmReq != nil {
record.IdempotencyKey = strings.TrimSpace(confirmReq.RequestID)
record.PaymentIntentID = strings.TrimSpace(confirmReq.PaymentIntentID)
record.QuoteRef = strings.TrimSpace(confirmReq.QuoteRef)
record.OutgoingLeg = strings.TrimSpace(confirmReq.Rail)
record.TargetChatID = strings.TrimSpace(confirmReq.TargetChatID)
record.RequestedMoney = confirmReq.RequestedMoney
record.IntentRef = strings.TrimSpace(confirmReq.IntentRef)
record.OperationRef = strings.TrimSpace(confirmReq.OperationRef)
// ExpiresAt is not used to derive an "expired" status — it can be kept for informational purposes only.
if confirmReq.TimeoutSeconds > 0 {
record.ExpiresAt = time.Now().Add(time.Duration(confirmReq.TimeoutSeconds) * time.Second)
}
@@ -641,12 +543,14 @@ func intentFromPayment(record *storagemodel.PaymentRecord) *model.PaymentGateway
return nil
}
return &model.PaymentGatewayIntent{
PaymentRef: strings.TrimSpace(record.PaymentRef),
PaymentIntentID: strings.TrimSpace(record.PaymentIntentID),
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
OutgoingLeg: strings.TrimSpace(record.OutgoingLeg),
QuoteRef: strings.TrimSpace(record.QuoteRef),
IntentRef: strings.TrimSpace(record.IntentRef),
OperationRef: strings.TrimSpace(record.OperationRef),
RequestedMoney: record.RequestedMoney,
TargetChatID: strings.TrimSpace(record.TargetChatID),
}
}
@@ -678,13 +582,17 @@ func intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, d
Currency: sourceCurrency,
}
}
paymentIntentID := strings.TrimSpace(req.GetClientReference())
paymentIntentID := strings.TrimSpace(req.GetIntentRef())
if paymentIntentID == "" {
paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID])
}
if paymentIntentID == "" {
return nil, merrors.InvalidArgument("submit_transfer: payment_intent_id is required")
}
paymentRef := strings.TrimSpace(req.PaymentRef)
if paymentRef == "" {
return nil, merrors.InvalidArgument("submit_transfer: payment_ref is required")
}
quoteRef := strings.TrimSpace(metadata[metadataQuoteRef])
targetChatID := strings.TrimSpace(metadata[metadataTargetChatID])
outgoingLeg := strings.TrimSpace(metadata[metadataOutgoingLeg])
@@ -695,12 +603,12 @@ func intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, d
targetChatID = strings.TrimSpace(defaultChatID)
}
return &model.PaymentGatewayIntent{
PaymentRef: paymentRef,
PaymentIntentID: paymentIntentID,
IdempotencyKey: idempotencyKey,
OutgoingLeg: outgoingLeg,
QuoteRef: quoteRef,
RequestedMoney: requestedMoney,
TargetChatID: targetChatID,
}, nil
}
@@ -708,15 +616,14 @@ 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,
RequestedAmount: req.GetAmount(),
Status: chainv1.TransferStatus_TRANSFER_CREATED,
}
}
@@ -724,20 +631,32 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
if record == nil {
return nil
}
var requested *moneyv1.Money
if req != nil && req.GetAmount() != nil {
requested = req.GetAmount()
} else {
requested = moneyFromPayment(record.RequestedMoney)
}
net := moneyFromPayment(record.ExecutedMoney)
status := chainv1.TransferStatus_TRANSFER_SUBMITTED
switch paymentStatus(record) {
case storagemodel.PaymentStatusExecuted:
status = chainv1.TransferStatus_TRANSFER_CONFIRMED
case storagemodel.PaymentStatusExpired:
net := moneyFromPayment(record.RequestedMoney)
var status chainv1.TransferStatus
switch record.Status {
case storagemodel.PaymentStatusSuccess:
status = chainv1.TransferStatus_TRANSFER_SUCCESS
case storagemodel.PaymentStatusCancelled:
status = chainv1.TransferStatus_TRANSFER_CANCELLED
case storagemodel.PaymentStatusFailed:
status = chainv1.TransferStatus_TRANSFER_FAILED
case storagemodel.PaymentStatusProcessing:
status = chainv1.TransferStatus_TRANSFER_PROCESSING
case storagemodel.PaymentStatusWaiting:
status = chainv1.TransferStatus_TRANSFER_WAITING
default:
status = chainv1.TransferStatus_TRANSFER_CREATED
}
transfer := &chainv1.Transfer{
TransferRef: strings.TrimSpace(record.IdempotencyKey),
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
@@ -745,11 +664,13 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
NetAmount: net,
Status: status,
}
if req != nil {
transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
transfer.Destination = req.GetDestination()
}
if !record.ExecutedAt.IsZero() {
ts := timestamppb.New(record.ExecutedAt)
transfer.CreatedAt = ts
@@ -761,6 +682,7 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
transfer.CreatedAt = timestamppb.New(record.CreatedAt)
}
}
return transfer
}
@@ -787,3 +709,27 @@ func readEnv(env string) string {
}
var _ grpcapp.Service = (*Service)(nil)
func statusFromConfirmationResult(r *model.ConfirmationResult) storagemodel.PaymentStatus {
if r == nil {
return storagemodel.PaymentStatusWaiting
}
switch r.Status {
case model.ConfirmationStatusConfirmed:
return storagemodel.PaymentStatusProcessing
case model.ConfirmationStatusClarified:
return storagemodel.PaymentStatusWaiting
case model.ConfirmationStatusRejected:
return storagemodel.PaymentStatusFailed
case model.ConfirmationStatusTimeout:
return storagemodel.PaymentStatusFailed
default:
return storagemodel.PaymentStatusFailed
}
}

View File

@@ -2,22 +2,23 @@ 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"
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model"
notification "github.com/tech/sendico/pkg/model/notification"
"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"
)
//
// FAKE STORES
//
type fakePaymentsStore struct {
mu sync.Mutex
records map[string]*storagemodel.PaymentRecord
@@ -26,6 +27,9 @@ type fakePaymentsStore struct {
func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
return nil, nil
}
return f.records[key], nil
}
@@ -67,286 +71,212 @@ func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
return f.tg
}
type captureProducer struct {
mu sync.Mutex
confirmationRequests []*model.ConfirmationRequest
executions []*model.PaymentGatewayExecution
}
//
// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
//
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()
}
}
type fakeBroker struct{}
func (f *fakeBroker) Publish(_ envelope.Envelope) error {
return nil
}
func (c *captureProducer) Reset() {
func (f *fakeBroker) Subscribe(event model.NotificationEvent) (<-chan envelope.Envelope, error) {
return nil, nil
}
func (f *fakeBroker) Unsubscribe(event model.NotificationEvent, subChan <-chan envelope.Envelope) error {
return nil
}
//
// CAPTURE ONLY TELEGRAM REACTIONS
//
type captureProducer struct {
mu sync.Mutex
reactions []envelope.Envelope
sig string
}
func (c *captureProducer) SendMessage(env envelope.Envelope) error {
if env.GetSignature().ToString() != c.sig {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
c.confirmationRequests = nil
c.executions = nil
c.reactions = append(c.reactions, env)
c.mu.Unlock()
return nil
}
func TestOnIntentCreatesConfirmationRequest(t *testing.T) {
//
// TESTS
//
func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) {
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"},
repo := &fakeRepo{
payments: &fakePaymentsStore{},
tg: &fakeTelegramStore{},
}
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{
RequestID: "x",
ChatID: "1",
MessageID: "2",
Emoji: "ok",
})
prod.Reset()
intent := &model.PaymentGatewayIntent{
prod := &captureProducer{
sig: sigEnv.GetSignature().ToString(),
}
svc := NewService(logger, repo, prod, &fakeBroker{}, Config{
Rail: "card",
SuccessReaction: "👍",
})
return svc, repo, prod
}
func TestConfirmed(t *testing.T) {
svc, repo, prod := newTestService(t)
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-1",
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)
}
record := repo.payments.records["idem-1"]
if record == nil {
t.Fatalf("expected pending payment to be stored")
}
if record.Status != storagemodel.PaymentStatusPending {
t.Fatalf("expected pending status, got %q", record.Status)
}
if record.RequestedMoney == nil || record.RequestedMoney.Amount != "10.50" {
t.Fatalf("requested money not stored correctly: %#v", record.RequestedMoney)
}
}
func TestIntentFromSubmitTransferUsesSourceMoney(t *testing.T) {
req := &chainv1.SubmitTransferRequest{
IdempotencyKey: "idem-1",
ClientReference: "pi-1",
Amount: &moneyv1.Money{Amount: "10.00", Currency: "EUR"},
Metadata: map[string]string{
metadataSourceAmount: "12.34",
metadataSourceCurrency: "USD",
},
}
intent, err := intentFromSubmitTransfer(req, "provider_settlement", "")
if err != nil {
t.Fatalf("intentFromSubmitTransfer error: %v", err)
}
if intent.RequestedMoney == nil || intent.RequestedMoney.Amount != "12.34" || intent.RequestedMoney.Currency != "USD" {
t.Fatalf("expected source money override, got: %#v", intent.RequestedMoney)
}
}
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"},
Status: storagemodel.PaymentStatusWaiting,
})
result := &model.ConfirmationResult{
RequestID: "idem-1",
Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
Status: model.ConfirmationStatusConfirmed,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-1"]
if rec.Status != storagemodel.PaymentStatusSuccess {
t.Fatalf("expected success, got %s", rec.Status)
}
if rec.RequestedMoney == nil {
t.Fatalf("requested money not set")
}
if rec.ExecutedAt.IsZero() {
t.Fatalf("executedAt not set")
}
if repo.tg.records["idem-1"] == nil {
t.Fatalf("telegram confirmation not stored")
}
if len(prod.reactions) != 1 {
t.Fatalf("reaction must be published")
}
}
func TestClarified(t *testing.T) {
svc, repo, prod := newTestService(t)
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-2",
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
OutgoingLeg: intent.OutgoingLeg,
RequestedMoney: intent.RequestedMoney,
Status: storagemodel.PaymentStatusPending,
IdempotencyKey: "idem-2",
Status: storagemodel.PaymentStatusWaiting,
})
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"},
Status: model.ConfirmationStatusClarified,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
t.Fatalf("onConfirmationResult error: %v", err)
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-2"]
if rec.Status != storagemodel.PaymentStatusWaiting {
t.Fatalf("clarified must not change status")
}
record := repo.payments.records["idem-2"]
if record == nil {
t.Fatalf("expected payment record to be stored")
if repo.tg.records["idem-2"] == nil {
t.Fatalf("telegram confirmation must be stored")
}
if record.Status != storagemodel.PaymentStatusExecuted {
t.Fatalf("expected executed status, got %q", record.Status)
}
if record.ExecutedMoney == nil || record.ExecutedMoney.Amount != "5" {
t.Fatalf("executed money not stored correctly: %#v", record.ExecutedMoney)
}
if repo.tg.records["idem-2"] == nil || repo.tg.records["idem-2"].RawReply.Text != "5 EUR" {
t.Fatalf("telegram reply not stored correctly")
if len(prod.reactions) != 0 {
t.Fatalf("clarified must not publish reaction")
}
}
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"},
}
func TestRejected(t *testing.T) {
svc, repo, prod := newTestService(t)
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-clarified",
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
OutgoingLeg: intent.OutgoingLeg,
RequestedMoney: intent.RequestedMoney,
Status: storagemodel.PaymentStatusPending,
IdempotencyKey: "idem-3",
PaymentIntentID: "pi-3",
QuoteRef: "quote-3",
OutgoingLeg: "card",
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"},
Status: storagemodel.PaymentStatusWaiting,
})
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"},
RequestID: "idem-3",
Status: model.ConfirmationStatusRejected,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
t.Fatalf("onConfirmationResult error: %v", err)
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-3"]
if rec.Status != storagemodel.PaymentStatusFailed {
t.Fatalf("expected failed")
}
record := repo.payments.records["idem-clarified"]
if record == nil || record.Status != storagemodel.PaymentStatusExecuted {
t.Fatalf("expected payment executed status, got %#v", record)
if repo.tg.records["idem-3"] == nil {
t.Fatalf("telegram confirmation must be stored")
}
if len(prod.reactions) != 0 {
t.Fatalf("rejected must not publish reaction")
}
}
func TestIdempotencyPreventsDuplicateWrites(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
repo := &fakeRepo{payments: &fakePaymentsStore{records: map[string]*storagemodel.PaymentRecord{
"idem-3": {IdempotencyKey: "idem-3", Status: storagemodel.PaymentStatusPending},
}}, 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 TestTimeout(t *testing.T) {
svc, repo, prod := newTestService(t)
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"},
}
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-4",
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
OutgoingLeg: intent.OutgoingLeg,
RequestedMoney: intent.RequestedMoney,
Status: storagemodel.PaymentStatusPending,
PaymentIntentID: "pi-4",
QuoteRef: "quote-4",
OutgoingLeg: "card",
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"},
Status: storagemodel.PaymentStatusWaiting,
})
result := &model.ConfirmationResult{
RequestID: "idem-4",
Status: model.ConfirmationStatusTimeout,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
t.Fatalf("onConfirmationResult error: %v", err)
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-4"]
if rec.Status != storagemodel.PaymentStatusFailed {
t.Fatalf("timeout must be failed")
}
record := repo.payments.records["idem-4"]
if record == nil || record.Status != storagemodel.PaymentStatusExpired {
t.Fatalf("expected expired status for timeout, got %#v", record)
}
}
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"},
}
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-reject",
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
OutgoingLeg: intent.OutgoingLeg,
RequestedMoney: intent.RequestedMoney,
Status: storagemodel.PaymentStatusPending,
})
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)
}
record := repo.payments.records["idem-reject"]
if record == nil || record.Status != storagemodel.PaymentStatusExpired {
t.Fatalf("expected expired status for rejection, got %#v", record)
}
if repo.tg.records["idem-reject"] == nil {
t.Fatalf("expected raw reply to be stored for rejection")
if repo.tg.records["idem-4"] == nil {
t.Fatalf("telegram confirmation must be stored")
}
if len(prod.reactions) != 0 {
t.Fatalf("timeout must not publish reaction")
}
}

View File

@@ -0,0 +1,77 @@
package gateway
import (
"context"
"github.com/tech/sendico/gateway/tgsettle/storage/model"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/pkg/payments/rail"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
"go.uber.org/zap"
)
func isFinalStatus(t *model.PaymentRecord) bool {
switch t.Status {
case model.PaymentStatusFailed, model.PaymentStatusSuccess, model.PaymentStatusCancelled:
return true
default:
return false
}
}
func toOpStatus(t *model.PaymentRecord) rail.OperationResult {
switch t.Status {
case model.PaymentStatusFailed:
return rail.OperationResultFailed
case model.PaymentStatusSuccess:
return rail.OperationResultSuccess
case model.PaymentStatusCancelled:
return rail.OperationResultCancelled
default:
panic("unexpected transfer status")
}
}
func toError(t *model.PaymentRecord) *gatewayv1.OperationError {
if t.Status == model.PaymentStatusSuccess {
return nil
}
return &gatewayv1.OperationError{
Message: t.FailureReason,
}
}
func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error {
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err))
return err
}
if isFinalStatus(record) {
s.emitTransferStatusEvent(ctx, record)
}
return nil
}
func (s *Service) emitTransferStatusEvent(_ context.Context, record *model.PaymentRecord) {
if s == nil || s.producer == nil || record == nil {
return
}
exec := pmodel.PaymentGatewayExecution{
PaymentIntentID: record.PaymentIntentID,
IdempotencyKey: record.IdempotencyKey,
ExecutedMoney: record.ExecutedMoney,
PaymentRef: record.PaymentRef,
Status: toOpStatus(record),
OperationRef: record.OperationRef,
Error: record.FailureReason,
TransferRef: record.ID.Hex(),
}
env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID))
}
}