Chimera Settle service

This commit is contained in:
Stephan D
2026-03-06 15:42:32 +01:00
parent ea5ec79a6e
commit 10bcdb4fe2
43 changed files with 8070 additions and 0 deletions

View File

@@ -0,0 +1,443 @@
package gateway
import (
"context"
"errors"
"regexp"
"strings"
"time"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`)
func (s *Service) startConfirmationTimeoutWatcher() {
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return
}
if s.timeoutCancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
s.timeoutCtx = ctx
s.timeoutCancel = cancel
s.timeoutWG.Add(1)
go func() {
defer s.timeoutWG.Done()
ticker := time.NewTicker(defaultConfirmationSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sweepExpiredConfirmations(ctx)
}
}
}()
}
func (s *Service) sweepExpiredConfirmations(ctx context.Context) {
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return
}
expired, err := s.repo.PendingConfirmations().ListExpired(ctx, time.Now(), 100)
if err != nil {
s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err))
return
}
for i := range expired {
pending := &expired[i]
if strings.TrimSpace(pending.RequestID) == "" {
continue
}
result := &model.ConfirmationResult{
RequestID: pending.RequestID,
Status: model.ConfirmationStatusTimeout,
}
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
s.logger.Warn("Failed to publish timeout confirmation result", zap.Error(err), zap.String("request_id", pending.RequestID))
continue
}
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
s.logger.Warn("Failed to remove expired pending confirmation", zap.Error(err), zap.String("request_id", pending.RequestID))
}
}
}
func (s *Service) persistPendingConfirmation(ctx context.Context, request *model.ConfirmationRequest) error {
if request == nil {
return merrors.InvalidArgument("confirmation request is nil", "request")
}
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return merrors.Internal("pending confirmations store unavailable")
}
timeout := request.TimeoutSeconds
if timeout <= 0 {
timeout = int32(defaultConfirmationTimeoutSeconds)
}
pending := &storagemodel.PendingConfirmation{
RequestID: strings.TrimSpace(request.RequestID),
TargetChatID: strings.TrimSpace(request.TargetChatID),
AcceptedUserIDs: normalizeStringList(request.AcceptedUserIDs),
RequestedMoney: request.RequestedMoney,
SourceService: strings.TrimSpace(request.SourceService),
Rail: strings.TrimSpace(request.Rail),
ExpiresAt: time.Now().Add(time.Duration(timeout) * time.Second),
}
return s.repo.PendingConfirmations().Upsert(ctx, pending)
}
func (s *Service) clearPendingConfirmation(ctx context.Context, requestID string) error {
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return nil
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return nil
}
return s.repo.PendingConfirmations().DeleteByRequestID(ctx, requestID)
}
func (s *Service) onConfirmationDispatch(ctx context.Context, dispatch *model.ConfirmationRequestDispatch) error {
if dispatch == nil {
return merrors.InvalidArgument("confirmation dispatch is nil", "dispatch")
}
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return merrors.Internal("pending confirmations store unavailable")
}
requestID := strings.TrimSpace(dispatch.RequestID)
messageID := strings.TrimSpace(dispatch.MessageID)
if requestID == "" {
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
}
if messageID == "" {
return merrors.InvalidArgument("confirmation message_id is required", "message_id")
}
if err := s.repo.PendingConfirmations().AttachMessage(ctx, requestID, messageID); err != nil {
if errors.Is(err, merrors.ErrNoData) {
s.logger.Info("Confirmation dispatch ignored: pending request not found",
zap.String("request_id", requestID),
zap.String("message_id", messageID))
return nil
}
s.logger.Warn("Failed to attach confirmation message id", zap.Error(err), zap.String("request_id", requestID), zap.String("message_id", messageID))
return err
}
s.logger.Info("Pending confirmation message attached", zap.String("request_id", requestID), zap.String("message_id", messageID))
return nil
}
func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) error {
if update == nil || update.Message == nil {
return nil
}
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return merrors.Internal("pending confirmations store unavailable")
}
message := update.Message
replyToID := strings.TrimSpace(message.ReplyToMessageID)
if replyToID == "" {
s.handleTreasuryTelegramUpdate(ctx, update)
return nil
}
replyFields := telegramReplyLogFields(update)
pending, err := s.repo.PendingConfirmations().FindByMessageID(ctx, replyToID)
if err != nil {
return err
}
if pending == nil {
if s.handleTreasuryTelegramUpdate(ctx, update) {
return nil
}
s.logger.Warn("Telegram confirmation reply dropped",
append(replyFields,
zap.String("outcome", "dropped"),
zap.String("reason", "no_pending_confirmation"),
)...)
return nil
}
replyFields = append(replyFields,
zap.String("request_id", strings.TrimSpace(pending.RequestID)),
zap.String("target_chat_id", strings.TrimSpace(pending.TargetChatID)),
)
if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) {
result := &model.ConfirmationResult{
RequestID: pending.RequestID,
Status: model.ConfirmationStatusTimeout,
}
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
return err
}
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
return err
}
s.logger.Info("Telegram confirmation reply processed",
append(replyFields,
zap.String("outcome", "processed"),
zap.String("result_status", string(result.Status)),
zap.String("reason", "expired_confirmation"),
)...)
return nil
}
if strings.TrimSpace(message.ChatID) != strings.TrimSpace(pending.TargetChatID) {
s.logger.Warn("Telegram confirmation reply dropped",
append(replyFields,
zap.String("outcome", "dropped"),
zap.String("reason", "chat_mismatch"),
zap.String("expected_chat_id", strings.TrimSpace(pending.TargetChatID)),
)...)
return nil
}
if !isUserAllowed(message.FromUserID, pending.AcceptedUserIDs) {
result := &model.ConfirmationResult{
RequestID: pending.RequestID,
Status: model.ConfirmationStatusRejected,
ParseError: "unauthorized_user",
RawReply: message,
}
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
return err
}
if e := s.sendTelegramText(ctx, &model.TelegramTextRequest{
RequestID: pending.RequestID,
ChatID: pending.TargetChatID,
ReplyToMessageID: message.MessageID,
Text: "Only approved users can confirm this payment.",
}); e != nil {
s.logger.Warn("Failed to create telegram text", append(replyFields, zap.Error(err))...)
}
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
return err
}
s.logger.Info("Telegram confirmation reply processed",
append(replyFields,
zap.String("outcome", "processed"),
zap.String("result_status", string(result.Status)),
zap.String("reason", "unauthorized_user"),
)...)
return nil
}
money, reason, err := parseConfirmationReply(message.Text)
if err != nil {
if markErr := s.repo.PendingConfirmations().MarkClarified(ctx, pending.RequestID); markErr != nil {
s.logger.Warn("Failed to mark confirmation as clarified", zap.Error(markErr), zap.String("request_id", pending.RequestID))
}
if e := s.sendTelegramText(ctx, &model.TelegramTextRequest{
RequestID: pending.RequestID,
ChatID: pending.TargetChatID,
ReplyToMessageID: message.MessageID,
Text: clarificationMessage(reason),
}); e != nil {
s.logger.Warn("Failed to create telegram text", append(replyFields, zap.Error(err))...)
}
s.logger.Warn("Telegram confirmation reply dropped",
append(replyFields,
zap.String("outcome", "dropped"),
zap.String("reason", "invalid_reply_format"),
zap.String("parse_reason", reason),
)...)
return nil
}
status := model.ConfirmationStatusConfirmed
if pending.Clarified {
status = model.ConfirmationStatusClarified
}
result := &model.ConfirmationResult{
RequestID: pending.RequestID,
Money: money,
RawReply: message,
Status: status,
}
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
return err
}
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
return err
}
s.logger.Info("Telegram confirmation reply processed",
append(replyFields,
zap.String("outcome", "processed"),
zap.String("result_status", string(result.Status)),
)...)
return nil
}
func (s *Service) handleTreasuryTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
if s == nil || s.treasury == nil || update == nil || update.Message == nil {
return false
}
return s.treasury.HandleUpdate(ctx, update)
}
func telegramReplyLogFields(update *model.TelegramWebhookUpdate) []zap.Field {
if update == nil || update.Message == nil {
return nil
}
message := update.Message
return []zap.Field{
zap.Int64("update_id", update.UpdateID),
zap.String("message_id", strings.TrimSpace(message.MessageID)),
zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)),
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
zap.String("from_user_id", strings.TrimSpace(message.FromUserID)),
}
}
func (s *Service) publishPendingConfirmationResult(pending *storagemodel.PendingConfirmation, result *model.ConfirmationResult) error {
if pending == nil || result == nil {
return merrors.InvalidArgument("pending confirmation context is required")
}
if s == nil || s.producer == nil {
return merrors.Internal("messaging producer is not configured")
}
sourceService := strings.TrimSpace(pending.SourceService)
if sourceService == "" {
sourceService = string(mservice.PaymentGateway)
}
rail := strings.TrimSpace(pending.Rail)
if rail == "" {
rail = s.rail
}
env := confirmations.ConfirmationResult(string(mservice.PaymentGateway), result, sourceService, rail)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish confirmation result", zap.Error(err),
zap.String("request_id", strings.TrimSpace(result.RequestID)),
zap.String("status", string(result.Status)),
zap.String("source_service", sourceService),
zap.String("rail", rail))
return err
}
return nil
}
func (s *Service) sendTelegramText(_ context.Context, request *model.TelegramTextRequest) error {
if request == nil {
return merrors.InvalidArgument("telegram text request is nil", "request")
}
if s == nil || s.producer == nil {
return merrors.Internal("messaging producer is not configured")
}
request.ChatID = strings.TrimSpace(request.ChatID)
request.Text = strings.TrimSpace(request.Text)
request.ReplyToMessageID = strings.TrimSpace(request.ReplyToMessageID)
if request.ChatID == "" || request.Text == "" {
return merrors.InvalidArgument("telegram chat_id and text are required", "chat_id", "text")
}
env := tnotifications.TelegramText(string(mservice.PaymentGateway), request)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish telegram text request", zap.Error(err),
zap.String("request_id", request.RequestID),
zap.String("chat_id", request.ChatID),
zap.String("reply_to_message_id", request.ReplyToMessageID))
return err
}
return nil
}
func isFinalConfirmationStatus(status model.ConfirmationStatus) bool {
switch status {
case model.ConfirmationStatusConfirmed, model.ConfirmationStatusRejected, model.ConfirmationStatusTimeout, model.ConfirmationStatusClarified:
return true
default:
return false
}
}
func isUserAllowed(userID string, allowed []string) bool {
allowed = normalizeStringList(allowed)
if len(allowed) == 0 {
return true
}
userID = strings.TrimSpace(userID)
if userID == "" {
return false
}
for _, id := range allowed {
if id == userID {
return true
}
}
return false
}
func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, "empty", merrors.InvalidArgument("empty reply")
}
parts := strings.Fields(text)
if len(parts) < 2 {
if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
return nil, "missing_currency", merrors.InvalidArgument("currency is required")
}
return nil, "missing_amount", merrors.InvalidArgument("amount is required")
}
if len(parts) > 2 {
return nil, "format", merrors.InvalidArgument("reply format is invalid")
}
amount := parts[0]
currency := parts[1]
if !amountPattern.MatchString(amount) {
return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
}
if !currencyPattern.MatchString(currency) {
return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid")
}
return &paymenttypes.Money{
Amount: amount,
Currency: strings.ToUpper(currency),
}, "", nil
}
func clarificationMessage(reason string) string {
switch reason {
case "missing_currency":
return "Currency code is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "missing_amount":
return "Amount is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "invalid_amount":
return "Amount must be a decimal number. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "invalid_currency":
return "Currency must be a code like USD or EUR. Reply with \"<amount> <currency>\"."
default:
return "Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
}
}
func normalizeStringList(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
if len(result) == 0 {
return nil
}
return result
}

View File

@@ -0,0 +1,413 @@
package gateway
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
const (
chsettleConnectorID = "chsettle"
connectorScenarioParam = "scenario"
connectorScenarioMetaKey = "chsettle_scenario"
)
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
return &connectorv1.GetCapabilitiesResponse{
Capabilities: &connectorv1.ConnectorCapabilities{
ConnectorType: chsettleConnectorID,
Version: "",
SupportedAccountKinds: nil,
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER},
OperationParams: chsettleOperationParams(),
},
}, nil
}
func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil
}
func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
return nil, merrors.NotImplemented("get_account: unsupported")
}
func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
return nil, merrors.NotImplemented("list_accounts: unsupported")
}
func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
return nil, merrors.NotImplemented("get_balance: unsupported")
}
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
if req == nil || req.GetOperation() == nil {
s.logger.Warn("Submit operation rejected", zap.String("reason", "operation is required"))
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
}
op := req.GetOperation()
s.logger.Debug("Submit operation request received",
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
zap.String("intent_ref", strings.TrimSpace(op.GetIntentRef())),
zap.String("operation_ref", strings.TrimSpace(op.GetOperationRef())))
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "idempotency_key is required"))...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
}
if op.GetType() != connectorv1.OperationType_TRANSFER {
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "unsupported operation type"))...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
}
reader := params.New(op.GetParams())
metadata := reader.StringMap("metadata")
if metadata == nil {
metadata = map[string]string{}
}
paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id"))
if paymentIntentID == "" {
paymentIntentID = strings.TrimSpace(reader.String("payment_ref"))
}
if paymentIntentID == "" {
paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID])
}
if paymentIntentID == "" {
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "payment_intent_id is required"))...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: payment_intent_id is required", op, "")}}, nil
}
source := operationAccountID(op.GetFrom())
if source == "" {
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "from.account is required"))...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account is required", op, "")}}, nil
}
dest, err := transferDestinationFromOperation(op)
if err != nil {
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.Error(err))...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
amount := op.GetMoney()
if amount == nil {
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "money is required"))...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
}
metadata[metadataPaymentIntentID] = paymentIntentID
quoteRef := strings.TrimSpace(reader.String("quote_ref"))
if quoteRef != "" {
metadata[metadataQuoteRef] = quoteRef
}
targetChatID := strings.TrimSpace(reader.String("target_chat_id"))
if targetChatID != "" {
metadata[metadataTargetChatID] = targetChatID
}
outgoingLeg := normalizeRail(reader.String("outgoing_leg"))
if outgoingLeg != "" {
metadata[metadataOutgoingLeg] = outgoingLeg
}
if scenario := strings.TrimSpace(reader.String(connectorScenarioParam)); scenario != "" {
metadata[connectorScenarioMetaKey] = scenario
}
s.logger.Debug("Submit operation parsed transfer metadata",
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
zap.String("payment_intent_id", paymentIntentID),
zap.String("quote_ref", quoteRef),
zap.String("target_chat_id", targetChatID),
zap.String("outgoing_leg", outgoingLeg),
zap.String("scenario_override", strings.TrimSpace(metadata[connectorScenarioMetaKey])))
normalizedAmount := normalizeMoneyForTransfer(amount)
logFields := append(operationLogFields(op),
zap.String("payment_intent_id", paymentIntentID),
zap.String("organization_ref", strings.TrimSpace(reader.String("organization_ref"))),
zap.String("source_wallet_ref", source),
zap.String("amount", strings.TrimSpace(normalizedAmount.GetAmount())),
zap.String("currency", strings.TrimSpace(normalizedAmount.GetCurrency())),
zap.String("quote_ref", quoteRef),
zap.String("operation_ref", req.Operation.GetOperationRef()),
zap.String("intent_ref", op.GetIntentRef()),
zap.String("outgoing_leg", outgoingLeg),
)
logFields = append(logFields, transferDestinationLogFields(dest)...)
s.logger.Debug("Submit operation forwarding to transfer handler", logFields...)
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: strings.TrimSpace(reader.String("organization_ref")),
SourceWalletRef: source,
Destination: dest,
Amount: normalizedAmount,
Metadata: metadata,
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))...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transfer := resp.GetTransfer()
operationID := strings.TrimSpace(transfer.GetOperationRef())
if operationID == "" {
s.logger.Warn("Submit operation transfer response missing operation_ref", append(logFields,
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
)...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{
Error: connectorError(connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE, "submit_operation: operation_ref is missing in transfer response", op, ""),
}}, nil
}
s.logger.Info("Submit operation transfer submitted", append(logFields,
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
zap.String("status", transfer.GetStatus().String()),
)...)
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: operationID,
Status: transferStatusToOperation(transfer.GetStatus()),
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
},
}, nil
}
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
s.logger.Warn("Get operation rejected", zap.String("reason", "operation_id is required"))
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
operationID := strings.TrimSpace(req.GetOperationId())
s.logger.Debug("Get operation request received", zap.String("operation_id", operationID))
if s.repo == nil || s.repo.Payments() == nil {
s.logger.Warn("Get operation storage unavailable", zap.String("operation_id", operationID))
return nil, merrors.Internal("get_operation: storage is not configured")
}
record, err := s.repo.Payments().FindByOperationRef(ctx, operationID)
if err != nil {
s.logger.Warn("Get operation lookup by operation_ref failed", zap.String("operation_id", operationID), zap.Error(err))
return nil, err
}
if record == nil {
s.logger.Info("Get operation not found", zap.String("operation_id", operationID))
return nil, status.Error(codes.NotFound, "operation not found")
}
return &connectorv1.GetOperationResponse{Operation: transferToOperation(transferFromPayment(record, nil))}, nil
}
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
return nil, merrors.NotImplemented("list_operations: unsupported")
}
func chsettleOperationParams() []*connectorv1.OperationParamSpec {
return []*connectorv1.OperationParamSpec{
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
{Key: "payment_intent_id", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "quote_ref", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "target_chat_id", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "outgoing_leg", Type: connectorv1.ParamType_STRING, Required: false},
{Key: connectorScenarioParam, Type: connectorv1.ParamType_STRING, Required: false},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false},
}},
}
}
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
if op == nil {
return nil, merrors.InvalidArgument("transfer: operation is required")
}
if to := op.GetTo(); to != nil {
if account := to.GetAccount(); account != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
}
if ext := to.GetExternal(); ext != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
}
}
return nil, merrors.InvalidArgument("transfer: to.account or to.external is required")
}
func normalizeMoneyForTransfer(m *moneyv1.Money) *moneyv1.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
if idx := strings.Index(currency, "-"); idx > 0 {
currency = currency[:idx]
}
return &moneyv1.Money{
Amount: strings.TrimSpace(m.GetAmount()),
Currency: currency,
}
}
func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
if transfer == nil {
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetOperationRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: transferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
}
params := map[string]interface{}{}
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
params["payment_ref"] = paymentRef
}
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
params["organization_ref"] = organizationRef
}
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
params["failure_reason"] = failureReason
}
if len(params) > 0 {
op.Params = structFromMap(params)
}
if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" {
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chsettleConnectorID,
AccountId: source,
}}}
}
if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chsettleConnectorID,
AccountId: strings.TrimSpace(d.ManagedWalletRef),
}}}
case *chainv1.TransferDestination_ExternalAddress:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
ExternalRef: strings.TrimSpace(d.ExternalAddress),
}}}
}
}
return op
}
func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
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_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_OPERATION_CANCELLED
case chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED:
fallthrough
default:
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
func operationAccountID(party *connectorv1.OperationParty) string {
if party == nil {
return ""
}
if account := party.GetAccount(); account != nil {
return strings.TrimSpace(account.GetAccountId())
}
return ""
}
func structFromMap(values map[string]interface{}) *structpb.Struct {
if len(values) == 0 {
return nil
}
result, err := structpb.NewStruct(values)
if err != nil {
return nil
}
return result
}
func operationLogFields(op *connectorv1.Operation) []zap.Field {
if op == nil {
return nil
}
return []zap.Field{
zap.String("operation_id", strings.TrimSpace(op.GetOperationId())),
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
zap.String("correlation_id", strings.TrimSpace(op.GetCorrelationId())),
zap.String("parent_intent_id", strings.TrimSpace(op.GetParentIntentId())),
zap.String("operation_type", op.GetType().String()),
zap.String("intent_ref", strings.TrimSpace(op.GetIntentRef())),
}
}
func transferDestinationLogFields(dest *chainv1.TransferDestination) []zap.Field {
if dest == nil {
return nil
}
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
return []zap.Field{
zap.String("destination_type", "managed_wallet"),
zap.String("destination_ref", strings.TrimSpace(d.ManagedWalletRef)),
}
case *chainv1.TransferDestination_ExternalAddress:
return []zap.Field{
zap.String("destination_type", "external_address"),
zap.String("destination_ref", strings.TrimSpace(d.ExternalAddress)),
}
default:
return []zap.Field{zap.String("destination_type", "unknown")}
}
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,
Message: strings.TrimSpace(message),
AccountId: strings.TrimSpace(accountID),
}
if op != nil {
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
err.OperationId = strings.TrimSpace(op.GetOperationId())
}
return err
}
func mapErrorCode(err error) connectorv1.ErrorCode {
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return connectorv1.ErrorCode_INVALID_PARAMS
case errors.Is(err, merrors.ErrNoData):
return connectorv1.ErrorCode_NOT_FOUND
case errors.Is(err, merrors.ErrNotImplemented):
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
case errors.Is(err, merrors.ErrInternal):
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
default:
return connectorv1.ErrorCode_PROVIDER_ERROR
}
}

View File

@@ -0,0 +1,119 @@
package gateway
import (
"context"
"testing"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestSubmitOperation_UsesOperationRefAsOperationID(t *testing.T) {
svc, _, _ := newTestService(t)
svc.chatID = "1"
req := &connectorv1.SubmitOperationRequest{
Operation: &connectorv1.Operation{
Type: connectorv1.OperationType_TRANSFER,
IdempotencyKey: "idem-settlement-1",
OperationRef: "payment-1:hop_2_settlement_fx_convert",
IntentRef: "intent-1",
Money: &moneyv1.Money{Amount: "1.00", Currency: "USDT"},
From: &connectorv1.OperationParty{
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chsettleConnectorID,
AccountId: "wallet-src",
}},
},
To: &connectorv1.OperationParty{
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chsettleConnectorID,
AccountId: "wallet-dst",
}},
},
Params: structFromMap(map[string]interface{}{
"payment_ref": "payment-1",
"organization_ref": "org-1",
}),
},
}
resp, err := svc.SubmitOperation(context.Background(), req)
if err != nil {
t.Fatalf("SubmitOperation returned error: %v", err)
}
if resp.GetReceipt() == nil {
t.Fatal("expected receipt")
}
if got := resp.GetReceipt().GetError(); got != nil {
t.Fatalf("expected no connector error, got: %v", got)
}
if got, want := resp.GetReceipt().GetOperationId(), "payment-1:hop_2_settlement_fx_convert"; got != want {
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
}
if got, want := resp.GetReceipt().GetProviderRef(), "idem-settlement-1"; got != want {
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
}
}
func TestGetOperation_UsesOperationRefIdentity(t *testing.T) {
svc, repo, _ := newTestService(t)
record := &storagemodel.PaymentRecord{
IdempotencyKey: "idem-settlement-2",
OperationRef: "payment-2:hop_2_settlement_fx_convert",
PaymentIntentID: "pi-2",
PaymentRef: "payment-2",
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
Status: storagemodel.PaymentStatusSuccess,
}
if err := repo.payments.Upsert(context.Background(), record); err != nil {
t.Fatalf("failed to seed payment record: %v", err)
}
resp, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
OperationId: "payment-2:hop_2_settlement_fx_convert",
})
if err != nil {
t.Fatalf("GetOperation returned error: %v", err)
}
if resp.GetOperation() == nil {
t.Fatal("expected operation")
}
if got, want := resp.GetOperation().GetOperationId(), "payment-2:hop_2_settlement_fx_convert"; got != want {
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
}
if got, want := resp.GetOperation().GetProviderRef(), "idem-settlement-2"; got != want {
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
}
}
func TestGetOperation_DoesNotResolveByIdempotencyKey(t *testing.T) {
svc, repo, _ := newTestService(t)
record := &storagemodel.PaymentRecord{
IdempotencyKey: "idem-settlement-3",
OperationRef: "payment-3:hop_2_settlement_fx_convert",
PaymentIntentID: "pi-3",
PaymentRef: "payment-3",
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
Status: storagemodel.PaymentStatusSuccess,
}
if err := repo.payments.Upsert(context.Background(), record); err != nil {
t.Fatalf("failed to seed payment record: %v", err)
}
_, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
OperationId: "idem-settlement-3",
})
if err == nil {
t.Fatal("expected not found error")
}
if status.Code(err) != codes.NotFound {
t.Fatalf("unexpected error code: got=%s want=%s", status.Code(err), codes.NotFound)
}
}

View File

@@ -0,0 +1,47 @@
package gateway
import (
"context"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
"github.com/tech/sendico/pkg/db/transaction"
me "github.com/tech/sendico/pkg/messaging/envelope"
)
type tgOutboxProvider interface {
Outbox() gatewayoutbox.Store
}
type tgTransactionProvider interface {
TransactionFactory() transaction.Factory
}
func (s *Service) outboxStore() gatewayoutbox.Store {
provider, ok := s.repo.(tgOutboxProvider)
if !ok || provider == nil {
return nil
}
return provider.Outbox()
}
func (s *Service) startOutboxReliableProducer() error {
if s == nil || s.repo == nil {
return nil
}
return s.outbox.Start(s.logger, s.producer, s.outboxStore(), s.msgCfg)
}
func (s *Service) sendWithOutbox(ctx context.Context, env me.Envelope) error {
if err := s.startOutboxReliableProducer(); err != nil {
return err
}
return s.outbox.Send(ctx, env)
}
func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
provider, ok := s.repo.(tgTransactionProvider)
if !ok || provider == nil || provider.TransactionFactory() == nil {
return cb(ctx)
}
return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb)
}

View File

@@ -0,0 +1,302 @@
package gateway
import (
"hash/fnv"
"strconv"
"strings"
"time"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
const (
scenarioMetadataKey = "chsettle_scenario"
scenarioMetadataAliasKey = "scenario"
)
type settlementScenario struct {
Name string
InitialStatus storagemodel.PaymentStatus
FinalStatus storagemodel.PaymentStatus
FinalDelay time.Duration
FailureReason string
}
type settlementScenarioTrace struct {
Source string
OverrideRaw string
OverrideNormalized string
AmountRaw string
AmountCurrency string
BucketSlot int
}
var scenarioFastSuccess = settlementScenario{
Name: "fast_success",
InitialStatus: storagemodel.PaymentStatusSuccess,
}
var scenarioSlowSuccess = settlementScenario{
Name: "slow_success",
InitialStatus: storagemodel.PaymentStatusWaiting,
FinalStatus: storagemodel.PaymentStatusSuccess,
FinalDelay: 30 * time.Second,
}
var scenarioFailImmediate = settlementScenario{
Name: "fail_immediate",
InitialStatus: storagemodel.PaymentStatusFailed,
FailureReason: "simulated_fail_immediate",
}
var scenarioFailTimeout = settlementScenario{
Name: "fail_timeout",
InitialStatus: storagemodel.PaymentStatusWaiting,
FinalStatus: storagemodel.PaymentStatusFailed,
FinalDelay: 45 * time.Second,
FailureReason: "simulated_fail_timeout",
}
var scenarioStuckPending = settlementScenario{
Name: "stuck_pending",
InitialStatus: storagemodel.PaymentStatusWaiting,
}
var scenarioRetryThenSuccess = settlementScenario{
Name: "retry_then_success",
InitialStatus: storagemodel.PaymentStatusProcessing,
FinalStatus: storagemodel.PaymentStatusSuccess,
FinalDelay: 25 * time.Second,
}
var scenarioWebhookDelayedSuccess = settlementScenario{
Name: "webhook_delayed_success",
InitialStatus: storagemodel.PaymentStatusWaiting,
FinalStatus: storagemodel.PaymentStatusSuccess,
FinalDelay: 60 * time.Second,
}
var scenarioSlowThenFail = settlementScenario{
Name: "slow_then_fail",
InitialStatus: storagemodel.PaymentStatusProcessing,
FinalStatus: storagemodel.PaymentStatusFailed,
FinalDelay: 75 * time.Second,
FailureReason: "simulated_slow_then_fail",
}
var scenarioPartialProgressStuck = settlementScenario{
Name: "partial_progress_stuck",
InitialStatus: storagemodel.PaymentStatusProcessing,
}
func resolveSettlementScenario(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) settlementScenario {
scenario, _ := resolveSettlementScenarioWithTrace(idempotencyKey, amount, metadata)
return scenario
}
func resolveSettlementScenarioWithTrace(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) (settlementScenario, settlementScenarioTrace) {
trace := settlementScenarioTrace{
BucketSlot: -1,
}
if amount != nil {
trace.AmountRaw = strings.TrimSpace(amount.Amount)
trace.AmountCurrency = strings.TrimSpace(amount.Currency)
}
overrideScenario, overrideRaw, overrideNormalized, overrideApplied := parseScenarioOverride(metadata)
if overrideRaw != "" {
trace.OverrideRaw = overrideRaw
trace.OverrideNormalized = overrideNormalized
}
if overrideApplied {
trace.Source = "explicit_override"
return overrideScenario, trace
}
slot, ok := amountModuloSlot(amount)
if ok {
if trace.OverrideRaw != "" {
trace.Source = "invalid_override_amount_bucket"
} else {
trace.Source = "amount_bucket"
}
trace.BucketSlot = slot
return scenarioBySlot(slot, idempotencyKey), trace
}
slot = hashModulo(idempotencyKey, 1000)
if trace.OverrideRaw != "" {
trace.Source = "invalid_override_idempotency_hash_bucket"
} else {
trace.Source = "idempotency_hash_bucket"
}
trace.BucketSlot = slot
return scenarioBySlot(slot, idempotencyKey), trace
}
func parseScenarioOverride(metadata map[string]string) (settlementScenario, string, string, bool) {
if len(metadata) == 0 {
return settlementScenario{}, "", "", false
}
overrideRaw := strings.TrimSpace(metadata[scenarioMetadataKey])
if overrideRaw == "" {
overrideRaw = strings.TrimSpace(metadata[scenarioMetadataAliasKey])
}
if overrideRaw == "" {
return settlementScenario{}, "", "", false
}
scenario, normalized, ok := scenarioByName(overrideRaw)
return scenario, overrideRaw, normalized, ok
}
func scenarioByName(value string) (settlementScenario, string, bool) {
key := normalizeScenarioName(value)
switch key {
case "fast_success", "success_fast", "instant_success":
return scenarioFastSuccess, key, true
case "slow_success", "success_slow":
return scenarioSlowSuccess, key, true
case "fail_immediate", "immediate_fail", "failed":
return scenarioFailImmediate, key, true
case "fail_timeout", "timeout_fail":
return scenarioFailTimeout, key, true
case "stuck", "stuck_pending", "pending_stuck":
return scenarioStuckPending, key, true
case "retry_then_success":
return scenarioRetryThenSuccess, key, true
case "webhook_delayed_success":
return scenarioWebhookDelayedSuccess, key, true
case "slow_then_fail":
return scenarioSlowThenFail, key, true
case "partial_progress_stuck":
return scenarioPartialProgressStuck, key, true
case "chaos", "chaos_random_seeded":
return scenarioBySlot(950, ""), key, true
default:
return settlementScenario{}, key, false
}
}
func normalizeScenarioName(value string) string {
key := strings.ToLower(strings.TrimSpace(value))
key = strings.ReplaceAll(key, "-", "_")
return key
}
func scenarioBySlot(slot int, seed string) settlementScenario {
switch {
case slot < 100:
return scenarioFastSuccess
case slot < 200:
return scenarioSlowSuccess
case slot < 300:
return scenarioFailImmediate
case slot < 400:
return scenarioFailTimeout
case slot < 500:
return scenarioStuckPending
case slot < 600:
return scenarioRetryThenSuccess
case slot < 700:
return scenarioWebhookDelayedSuccess
case slot < 800:
return scenarioSlowThenFail
case slot < 900:
return scenarioPartialProgressStuck
default:
return chaosScenario(seed)
}
}
func chaosScenario(seed string) settlementScenario {
choices := []settlementScenario{
scenarioFastSuccess,
scenarioSlowSuccess,
scenarioFailImmediate,
scenarioFailTimeout,
scenarioStuckPending,
scenarioSlowThenFail,
}
idx := hashModulo(seed, len(choices))
return choices[idx]
}
func amountModuloSlot(amount *paymenttypes.Money) (int, bool) {
if amount == nil {
return 0, false
}
raw := strings.TrimSpace(amount.Amount)
if raw == "" {
return 0, false
}
sign := 1
if strings.HasPrefix(raw, "+") {
raw = strings.TrimPrefix(raw, "+")
}
if strings.HasPrefix(raw, "-") {
sign = -1
raw = strings.TrimPrefix(raw, "-")
}
parts := strings.SplitN(raw, ".", 3)
if len(parts) == 0 || len(parts) > 2 {
return 0, false
}
whole := parts[0]
if whole == "" || !digitsOnly(whole) {
return 0, false
}
frac := "00"
if len(parts) == 2 {
f := parts[1]
if f == "" || !digitsOnly(f) {
return 0, false
}
if len(f) >= 2 {
frac = f[:2]
} else {
frac = f + "0"
}
}
wholeMod := digitsMod(whole, 10)
fracVal, _ := strconv.Atoi(frac)
slot := (wholeMod*100 + fracVal) % 1000
if sign < 0 {
slot = (-slot + 1000) % 1000
}
return slot, true
}
func digitsOnly(value string) bool {
if value == "" {
return false
}
for i := 0; i < len(value); i++ {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return true
}
func digitsMod(value string, mod int) int {
if mod <= 0 {
return 0
}
result := 0
for i := 0; i < len(value); i++ {
digit := int(value[i] - '0')
result = (result*10 + digit) % mod
}
return result
}
func hashModulo(input string, mod int) int {
if mod <= 0 {
return 0
}
h := fnv.New32a()
_, _ = h.Write([]byte(strings.TrimSpace(input)))
return int(h.Sum32() % uint32(mod))
}
func (s settlementScenario) delayedTransitionEnabled() bool {
return s.FinalStatus != "" && s.FinalDelay > 0
}

View File

@@ -0,0 +1,105 @@
package gateway
import (
"context"
"testing"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
func TestResolveSettlementScenario_AmountBuckets(t *testing.T) {
tests := []struct {
name string
amount string
want string
}{
{name: "bucket_000_fast_success", amount: "10.00", want: scenarioFastSuccess.Name},
{name: "bucket_100_slow_success", amount: "11.00", want: scenarioSlowSuccess.Name},
{name: "bucket_200_fail_immediate", amount: "12.00", want: scenarioFailImmediate.Name},
{name: "bucket_300_fail_timeout", amount: "13.00", want: scenarioFailTimeout.Name},
{name: "bucket_400_stuck_pending", amount: "14.00", want: scenarioStuckPending.Name},
{name: "bucket_500_retry_then_success", amount: "15.00", want: scenarioRetryThenSuccess.Name},
{name: "bucket_600_webhook_delayed_success", amount: "16.00", want: scenarioWebhookDelayedSuccess.Name},
{name: "bucket_700_slow_then_fail", amount: "17.00", want: scenarioSlowThenFail.Name},
{name: "bucket_800_partial_progress_stuck", amount: "18.00", want: scenarioPartialProgressStuck.Name},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := resolveSettlementScenario("idem-"+tc.name, &paymenttypes.Money{Amount: tc.amount, Currency: "USD"}, nil)
if got.Name != tc.want {
t.Fatalf("scenario mismatch: got=%q want=%q", got.Name, tc.want)
}
})
}
}
func TestResolveSettlementScenario_ExplicitOverride(t *testing.T) {
got := resolveSettlementScenario("idem-override", &paymenttypes.Money{Amount: "10.00", Currency: "USD"}, map[string]string{
scenarioMetadataKey: "stuck",
})
if got.Name != scenarioStuckPending.Name {
t.Fatalf("scenario mismatch: got=%q want=%q", got.Name, scenarioStuckPending.Name)
}
}
func TestSubmitTransfer_UsesImmediateFailureScenario(t *testing.T) {
svc, repo, _ := newTestService(t)
resp, err := svc.SubmitTransfer(context.Background(), &chainv1.SubmitTransferRequest{
IdempotencyKey: "idem-immediate-fail",
IntentRef: "intent-immediate-fail",
OperationRef: "op-immediate-fail",
PaymentRef: "payment-immediate-fail",
Amount: &moneyv1.Money{Amount: "9.99", Currency: "USD"},
Metadata: map[string]string{
scenarioMetadataAliasKey: "fail_immediate",
},
})
if err != nil {
t.Fatalf("submit transfer failed: %v", err)
}
if got, want := resp.GetTransfer().GetStatus(), chainv1.TransferStatus_TRANSFER_FAILED; got != want {
t.Fatalf("transfer status mismatch: got=%v want=%v", got, want)
}
record := repo.payments.records["idem-immediate-fail"]
if record == nil {
t.Fatalf("expected payment record")
}
if got, want := record.Status, storagemodel.PaymentStatusFailed; got != want {
t.Fatalf("record status mismatch: got=%s want=%s", got, want)
}
if got, want := record.Scenario, scenarioFailImmediate.Name; got != want {
t.Fatalf("record scenario mismatch: got=%q want=%q", got, want)
}
}
func TestApplyScenarioTransition_SetsFinalSuccess(t *testing.T) {
svc, repo, _ := newTestService(t)
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-transition-success",
IntentRef: "intent-transition-success",
OperationRef: "op-transition-success",
PaymentRef: "payment-transition-success",
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USD"},
Status: storagemodel.PaymentStatusWaiting,
Scenario: scenarioSlowSuccess.Name,
})
svc.applyScenarioTransition("idem-transition-success", scenarioSlowSuccess)
record := repo.payments.records["idem-transition-success"]
if record == nil {
t.Fatalf("expected payment record")
}
if got, want := record.Status, storagemodel.PaymentStatusSuccess; got != want {
t.Fatalf("record status mismatch: got=%s want=%s", got, want)
}
if record.ExecutedMoney == nil {
t.Fatalf("expected executed money")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,417 @@
package gateway
import (
"context"
"sync"
"testing"
"time"
"github.com/tech/sendico/gateway/chsettle/storage"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
"github.com/tech/sendico/pkg/discovery"
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"
"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
}
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
}
func (f *fakePaymentsStore) FindByOperationRef(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
return nil, nil
}
for _, record := range f.records {
if record != nil && record.OperationRef == key {
return record, nil
}
}
return nil, nil
}
func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
f.records = map[string]*storagemodel.PaymentRecord{}
}
f.records[record.IdempotencyKey] = record
return nil
}
type fakeTelegramStore struct {
mu sync.Mutex
records map[string]*storagemodel.TelegramConfirmation
}
func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.TelegramConfirmation) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
f.records = map[string]*storagemodel.TelegramConfirmation{}
}
f.records[record.RequestID] = record
return nil
}
type fakeRepo struct {
payments *fakePaymentsStore
tg *fakeTelegramStore
pending *fakePendingStore
treasury storage.TreasuryRequestsStore
users storage.TreasuryTelegramUsersStore
}
func (f *fakeRepo) Payments() storage.PaymentsStore {
return f.payments
}
func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
return f.tg
}
func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore {
return f.pending
}
func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore {
return f.treasury
}
func (f *fakeRepo) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore {
return f.users
}
type fakePendingStore struct {
mu sync.Mutex
records map[string]*storagemodel.PendingConfirmation
}
func (f *fakePendingStore) Upsert(_ context.Context, record *storagemodel.PendingConfirmation) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
f.records = map[string]*storagemodel.PendingConfirmation{}
}
cp := *record
f.records[record.RequestID] = &cp
return nil
}
func (f *fakePendingStore) FindByRequestID(_ context.Context, requestID string) (*storagemodel.PendingConfirmation, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
return nil, nil
}
return f.records[requestID], nil
}
func (f *fakePendingStore) FindByMessageID(_ context.Context, messageID string) (*storagemodel.PendingConfirmation, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, record := range f.records {
if record != nil && record.MessageID == messageID {
return record, nil
}
}
return nil, nil
}
func (f *fakePendingStore) MarkClarified(_ context.Context, requestID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if record := f.records[requestID]; record != nil {
record.Clarified = true
}
return nil
}
func (f *fakePendingStore) AttachMessage(_ context.Context, requestID string, messageID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if record := f.records[requestID]; record != nil {
if record.MessageID == "" {
record.MessageID = messageID
}
}
return nil
}
func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.records, requestID)
return nil
}
func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]storagemodel.PendingConfirmation, error) {
f.mu.Lock()
defer f.mu.Unlock()
if limit <= 0 {
limit = 100
}
result := make([]storagemodel.PendingConfirmation, 0)
for _, record := range f.records {
if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) {
continue
}
result = append(result, *record)
if int64(len(result)) >= limit {
break
}
}
return result, nil
}
//
// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
//
type fakeBroker struct{}
func (f *fakeBroker) Publish(_ envelope.Envelope) error {
return nil
}
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()
c.reactions = append(c.reactions, env)
c.mu.Unlock()
return nil
}
//
// TESTS
//
func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) {
logger := mloggerfactory.NewLogger(false)
repo := &fakeRepo{
payments: &fakePaymentsStore{},
tg: &fakeTelegramStore{},
pending: &fakePendingStore{},
}
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{
RequestID: "x",
ChatID: "1",
MessageID: "2",
Emoji: "ok",
})
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",
QuoteRef: "quote-1",
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",
Status: storagemodel.PaymentStatusWaiting,
})
result := &model.ConfirmationResult{
RequestID: "idem-2",
Status: model.ConfirmationStatusClarified,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-2"]
if rec.Status != storagemodel.PaymentStatusWaiting {
t.Fatalf("clarified must not change status")
}
if repo.tg.records["idem-2"] == nil {
t.Fatalf("telegram confirmation must be stored")
}
if len(prod.reactions) != 0 {
t.Fatalf("clarified must not publish reaction")
}
}
func TestRejected(t *testing.T) {
svc, repo, prod := newTestService(t)
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
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-3",
Status: model.ConfirmationStatusRejected,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-3"]
if rec.Status != storagemodel.PaymentStatusFailed {
t.Fatalf("expected failed")
}
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 TestTimeout(t *testing.T) {
svc, repo, prod := newTestService(t)
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-4",
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"},
}
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-4"]
if rec.Status != storagemodel.PaymentStatusFailed {
t.Fatalf("timeout must be failed")
}
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")
}
}
func TestIntentFromSubmitTransfer_NormalizesOutgoingLeg(t *testing.T) {
intent, err := intentFromSubmitTransfer(&chainv1.SubmitTransferRequest{
IdempotencyKey: "idem-5",
IntentRef: "pi-5",
OperationRef: "op-5",
PaymentRef: "pay-5",
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
Metadata: map[string]string{
metadataOutgoingLeg: "card",
},
}, "provider_settlement", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := intent.OutgoingLeg, discovery.RailCardPayout; got != want {
t.Fatalf("unexpected outgoing leg: got=%q want=%q", got, want)
}
}

View File

@@ -0,0 +1,108 @@
package gateway
import (
"context"
"github.com/tech/sendico/gateway/chsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
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"
"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, error) {
switch t.Status {
case model.PaymentStatusFailed:
return rail.OperationResultFailed, nil
case model.PaymentStatusSuccess:
return rail.OperationResultSuccess, nil
case model.PaymentStatusCancelled:
return rail.OperationResultCancelled, nil
default:
return rail.OperationResultFailed, merrors.InvalidArgument("unexpected transfer status", "payment.status")
}
}
func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error {
if record == nil {
return merrors.InvalidArgument("payment record is required", "record")
}
s.logger.Debug("Persisting transfer status",
zap.String("idempotency_key", record.IdempotencyKey),
zap.String("payment_ref", record.PaymentIntentID),
zap.String("status", string(record.Status)),
zap.Bool("is_final", isFinalStatus(record)))
if !isFinalStatus(record) {
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
}
s.logger.Debug("Transfer status persisted (non-final)",
zap.String("idempotency_key", record.IdempotencyKey),
zap.String("status", string(record.Status)))
return nil
}
_, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
if upsertErr := s.repo.Payments().Upsert(txCtx, record); upsertErr != nil {
return nil, upsertErr
}
if isFinalStatus(record) {
if emitErr := s.emitTransferStatusEvent(txCtx, record); emitErr != nil {
return nil, emitErr
}
}
return nil, nil
})
if 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
}
s.logger.Info("Transfer status persisted (final)",
zap.String("idempotency_key", record.IdempotencyKey),
zap.String("status", string(record.Status)))
return nil
}
func (s *Service) emitTransferStatusEvent(ctx context.Context, record *model.PaymentRecord) error {
if s == nil || record == nil {
return nil
}
if s.producer == nil || s.outboxStore() == nil {
return nil
}
status, err := toOpStatus(record)
if err != nil {
s.logger.Warn("Failed to map transfer status for transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID))
return err
}
exec := pmodel.PaymentGatewayExecution{
PaymentIntentID: record.PaymentIntentID,
IdempotencyKey: record.IdempotencyKey,
ExecutedMoney: record.ExecutedMoney,
PaymentRef: record.PaymentRef,
Status: status,
OperationRef: record.OperationRef,
Error: record.FailureReason,
TransferRef: record.ID.Hex(),
}
env := paymentgateway.PaymentGatewayExecution(mservice.ChSettle, &exec)
if sendErr := s.sendWithOutbox(ctx, env); sendErr != nil {
s.logger.Warn("Failed to publish transfer status event", zap.Error(sendErr), mzap.ObjRef("transfer_ref", record.ID))
return sendErr
}
return nil
}