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 tgsettleConnectorID = "tgsettle" func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { return &connectorv1.GetCapabilitiesResponse{ Capabilities: &connectorv1.ConnectorCapabilities{ ConnectorType: tgsettleConnectorID, Version: "", SupportedAccountKinds: nil, SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER}, OperationParams: tgsettleOperationParams(), }, }, 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() 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 } 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)...) 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() 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: strings.TrimSpace(transfer.GetTransferRef()), 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()) 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 { 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 tgsettleOperationParams() []*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: "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.GetTransferRef()), 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: tgsettleConnectorID, 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: tgsettleConnectorID, 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 } }