refactored payment orchestration
This commit is contained in:
@@ -45,5 +45,5 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
|
||||
)
|
||||
|
||||
@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -11,21 +11,28 @@ import (
|
||||
type PaymentStatus string
|
||||
|
||||
const (
|
||||
PaymentStatusPending PaymentStatus = "pending"
|
||||
PaymentStatusExpired PaymentStatus = "expired"
|
||||
PaymentStatusExecuted PaymentStatus = "executed"
|
||||
PaymentStatusCreated PaymentStatus = "created" // created
|
||||
PaymentStatusProcessing PaymentStatus = "processing" // processing
|
||||
PaymentStatusWaiting PaymentStatus = "waiting" // waiting external action
|
||||
PaymentStatusSuccess PaymentStatus = "success" // final success
|
||||
PaymentStatusFailed PaymentStatus = "failed" // final failure
|
||||
PaymentStatusCancelled PaymentStatus = "cancelled" // cancelled final
|
||||
)
|
||||
|
||||
type PaymentRecord struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"`
|
||||
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
|
||||
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
|
||||
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
|
||||
IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"`
|
||||
PaymentRef string `bson:"paymentRef,omitempty" json:"payment_ref,omitempty"`
|
||||
OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"`
|
||||
TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"`
|
||||
RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"`
|
||||
ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"`
|
||||
Status PaymentStatus `bson:"status,omitempty" json:"status,omitempty"`
|
||||
FailureReason string `bson:"failureReason,omitempty" json:"Failure_reason,omitempty"`
|
||||
CreatedAt time.Time `bson:"createdAt,omitempty" json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updated_at,omitempty"`
|
||||
ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"`
|
||||
|
||||
@@ -85,6 +85,9 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
|
||||
if record.IdempotencyKey == "" {
|
||||
return merrors.InvalidArgument("idempotency key is required", "idempotency_key")
|
||||
}
|
||||
if record.IntentRef == "" {
|
||||
return merrors.InvalidArgument("intention reference key is required", "intent_ref")
|
||||
}
|
||||
now := time.Now()
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = now
|
||||
|
||||
Reference in New Issue
Block a user