Chimera Settle service
This commit is contained in:
413
api/gateway/chsettle/internal/service/gateway/connector.go
Normal file
413
api/gateway/chsettle/internal/service/gateway/connector.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user