package gateway import ( "context" "errors" "strings" "github.com/shopspring/decimal" "github.com/tech/sendico/gateway/aurora/internal/appversion" "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" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" "google.golang.org/protobuf/types/known/structpb" ) const connectorTypeID = "mntx" func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { return &connectorv1.GetCapabilitiesResponse{ Capabilities: &connectorv1.ConnectorCapabilities{ ConnectorType: connectorTypeID, Version: appversion.Create().Short(), SupportedAccountKinds: nil, SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT}, OperationParams: connectorOperationParams(), }, }, 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 { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil } op := req.GetOperation() idempotencyKey := strings.TrimSpace(op.GetIdempotencyKey()) if idempotencyKey == "" { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil } operationRef := strings.TrimSpace(op.GetOperationRef()) if operationRef == "" { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation_ref is required", op, "")}}, nil } intentRef := strings.TrimSpace(op.GetIntentRef()) if intentRef == "" { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: intent_ref is required", op, "")}}, nil } if op.GetType() != connectorv1.OperationType_PAYOUT { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil } reader := params.New(op.GetParams()) amountMinor, currency, err := payoutAmount(op, reader) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil } parentPaymentRef := strings.TrimSpace(reader.String("parent_payment_ref")) payoutID := operationIDForRequest(operationRef) if strings.TrimSpace(reader.String("card_token")) != "" { resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency)) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil } return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil } cr := buildCardPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency) resp, err := s.CreateCardPayout(ctx, cr) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil } return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil } func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) { if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { return nil, merrors.InvalidArgument("get_operation: operation_id is required") } operationRef := strings.TrimSpace(req.GetOperationId()) if s.storage == nil || s.storage.Payouts() == nil { return nil, merrors.Internal("get_operation: storage is not configured") } payout, err := s.storage.Payouts().FindByOperationRef(ctx, operationRef) if err != nil { return nil, err } if payout == nil { return nil, merrors.NoData("payout not found") } return &connectorv1.GetOperationResponse{Operation: payoutToOperation(StateToProto(payout))}, nil } func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { return nil, merrors.NotImplemented("list_operations: unsupported") } func connectorOperationParams() []*connectorv1.OperationParamSpec { return []*connectorv1.OperationParamSpec{ {OperationType: connectorv1.OperationType_PAYOUT, Params: []*connectorv1.ParamSpec{ {Key: "customer_id", Type: connectorv1.ParamType_STRING, Required: true}, {Key: "customer_first_name", Type: connectorv1.ParamType_STRING, Required: true}, {Key: "customer_last_name", Type: connectorv1.ParamType_STRING, Required: true}, {Key: "customer_ip", Type: connectorv1.ParamType_STRING, Required: true}, {Key: "card_token", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "card_pan", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "card_exp_year", Type: connectorv1.ParamType_INT, Required: false}, {Key: "card_exp_month", Type: connectorv1.ParamType_INT, Required: false}, {Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false}, {Key: "project_id", Type: connectorv1.ParamType_INT, Required: false}, {Key: "parent_payment_ref", Type: connectorv1.ParamType_STRING, Required: true}, {Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_city", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_address", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_zip", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "masked_pan", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false}, }}, } } func payoutAmount(op *connectorv1.Operation, reader params.Reader) (int64, string, error) { if op == nil { return 0, "", merrors.InvalidArgument("payout: operation is required") } currency := currencyFromOperation(op) if currency == "" { return 0, "", merrors.InvalidArgument("payout: currency is required") } if minor, ok := reader.Int64("amount_minor"); ok && minor > 0 { return minor, currency, nil } money := op.GetMoney() if money == nil { return 0, "", merrors.InvalidArgument("payout: money is required") } amount := strings.TrimSpace(money.GetAmount()) if amount == "" { return 0, "", merrors.InvalidArgument("payout: amount is required") } dec, err := decimal.NewFromString(amount) if err != nil { return 0, "", merrors.InvalidArgument("payout: invalid amount") } minor := dec.Mul(decimal.NewFromInt(100)).IntPart() return minor, currency, nil } func currencyFromOperation(op *connectorv1.Operation) string { if op == nil || op.GetMoney() == nil { return "" } currency := strings.TrimSpace(op.GetMoney().GetCurrency()) if idx := strings.Index(currency, "-"); idx > 0 { currency = currency[:idx] } return strings.ToUpper(currency) } func operationIDForRequest(operationRef string) string { return strings.TrimSpace(operationRef) } func metadataFromReader(reader params.Reader) map[string]string { metadata := reader.StringMap("metadata") if len(metadata) == 0 { return nil } return metadata } func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { operationRef = strings.TrimSpace(operationRef) payoutID = strings.TrimSpace(payoutID) if operationRef != "" { payoutID = "" } req := &mntxv1.CardTokenPayoutRequest{ PayoutId: payoutID, ParentPaymentRef: strings.TrimSpace(parentPaymentRef), ProjectId: readerInt64(reader, "project_id"), CustomerId: strings.TrimSpace(reader.String("customer_id")), CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")), CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")), CustomerIp: strings.TrimSpace(reader.String("customer_ip")), CustomerZip: strings.TrimSpace(reader.String("customer_zip")), CustomerCountry: strings.TrimSpace(reader.String("customer_country")), CustomerState: strings.TrimSpace(reader.String("customer_state")), CustomerCity: strings.TrimSpace(reader.String("customer_city")), CustomerAddress: strings.TrimSpace(reader.String("customer_address")), AmountMinor: amountMinor, Currency: currency, CardToken: strings.TrimSpace(reader.String("card_token")), CardHolder: strings.TrimSpace(reader.String("card_holder")), MaskedPan: strings.TrimSpace(reader.String("masked_pan")), Metadata: metadataFromReader(reader), OperationRef: operationRef, IdempotencyKey: strings.TrimSpace(idempotencyKey), IntentRef: strings.TrimSpace(intentRef), } return req } func buildCardPayoutRequestFromParams(reader params.Reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest { operationRef = strings.TrimSpace(operationRef) payoutID = strings.TrimSpace(payoutID) if operationRef != "" { payoutID = "" } return &mntxv1.CardPayoutRequest{ PayoutId: payoutID, ParentPaymentRef: strings.TrimSpace(parentPaymentRef), ProjectId: readerInt64(reader, "project_id"), CustomerId: strings.TrimSpace(reader.String("customer_id")), CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")), CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")), CustomerIp: strings.TrimSpace(reader.String("customer_ip")), CustomerZip: strings.TrimSpace(reader.String("customer_zip")), CustomerCountry: strings.TrimSpace(reader.String("customer_country")), CustomerState: strings.TrimSpace(reader.String("customer_state")), CustomerCity: strings.TrimSpace(reader.String("customer_city")), CustomerAddress: strings.TrimSpace(reader.String("customer_address")), AmountMinor: amountMinor, Currency: currency, CardPan: strings.TrimSpace(reader.String("card_pan")), CardExpYear: uint32(readerInt64(reader, "card_exp_year")), CardExpMonth: uint32(readerInt64(reader, "card_exp_month")), CardHolder: strings.TrimSpace(reader.String("card_holder")), Metadata: metadataFromReader(reader), OperationRef: operationRef, IdempotencyKey: strings.TrimSpace(idempotencyKey), IntentRef: strings.TrimSpace(intentRef), } } func readerInt64(reader params.Reader, key string) int64 { if v, ok := reader.Int64(key); ok { return v } return 0 } func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt { if state == nil { return &connectorv1.OperationReceipt{ Status: connectorv1.OperationStatus_OPERATION_PROCESSING, } } return &connectorv1.OperationReceipt{ OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())), Status: payoutStatusToOperation(state.GetStatus()), ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), } } func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation { if state == nil { return nil } op := &connectorv1.Operation{ OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())), Type: connectorv1.OperationType_PAYOUT, Status: payoutStatusToOperation(state.GetStatus()), Money: &moneyv1.Money{ Amount: minorToDecimal(state.GetAmountMinor()), Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())), }, ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), IntentRef: strings.TrimSpace(state.GetIntentRef()), OperationRef: strings.TrimSpace(state.GetOperationRef()), CreatedAt: state.GetCreatedAt(), UpdatedAt: state.GetUpdatedAt(), } params := map[string]interface{}{} if paymentRef := strings.TrimSpace(state.GetParentPaymentRef()); paymentRef != "" { params["payment_ref"] = paymentRef params["parent_payment_ref"] = paymentRef } if providerCode := strings.TrimSpace(state.GetProviderCode()); providerCode != "" { params["provider_code"] = providerCode } if providerMessage := strings.TrimSpace(state.GetProviderMessage()); providerMessage != "" { params["provider_message"] = providerMessage params["failure_reason"] = providerMessage } if len(params) > 0 { op.Params = structFromMap(params) } return op } func minorToDecimal(amount int64) string { dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100)) return dec.StringFixed(2) } func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus { switch status { case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: return connectorv1.OperationStatus_OPERATION_CREATED case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: return connectorv1.OperationStatus_OPERATION_WAITING case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: return connectorv1.OperationStatus_OPERATION_SUCCESS case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: return connectorv1.OperationStatus_OPERATION_FAILED case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: return connectorv1.OperationStatus_OPERATION_CANCELLED default: return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED } } 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 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 } }