package client import ( "context" "strings" "time" "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/merrors" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" "go.uber.org/zap" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/types/known/structpb" ) // Client wraps the Monetix gateway gRPC API. type Client interface { CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) Close() error } type grpcConnectorClient interface { SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) } type gatewayClient struct { conn *grpc.ClientConn client grpcConnectorClient cfg Config logger *zap.Logger } // New dials the Monetix gateway. func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) { cfg.setDefaults() if strings.TrimSpace(cfg.Address) == "" { return nil, merrors.InvalidArgument("mntx: address is required") } dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout) defer cancel() dialOpts := make([]grpc.DialOption, 0, len(opts)+1) dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) dialOpts = append(dialOpts, opts...) conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...) if err != nil { return nil, merrors.Internal("mntx: dial failed: "+err.Error()) } return &gatewayClient{ conn: conn, client: connectorv1.NewConnectorServiceClient(conn), cfg: cfg, logger: cfg.Logger, }, nil } func (g *gatewayClient) Close() error { if g.conn != nil { return g.conn.Close() } return nil } func (g *gatewayClient) callContext(ctx context.Context, method string) (context.Context, context.CancelFunc) { if ctx == nil { ctx = context.Background() } timeout := g.cfg.CallTimeout if timeout <= 0 { timeout = 5 * time.Second } if g.logger != nil { fields := []zap.Field{ zap.String("method", method), zap.Duration("timeout", timeout), } if deadline, ok := ctx.Deadline(); ok { fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline))) } g.logger.Info("Mntx gateway client call timeout applied", fields...) } return context.WithTimeout(ctx, timeout) } func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { ctx, cancel := g.callContext(ctx, "CreateCardPayout") defer cancel() operation, err := operationFromCardPayout(req) if err != nil { return nil, err } resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) if err != nil { return nil, err } if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { return nil, connectorError(resp.GetReceipt().GetError()) } return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil } func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout") defer cancel() operation, err := operationFromTokenPayout(req) if err != nil { return nil, err } resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) if err != nil { return nil, err } if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { return nil, connectorError(resp.GetReceipt().GetError()) } return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil } func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus") defer cancel() if req == nil || strings.TrimSpace(req.GetPayoutId()) == "" { return nil, merrors.InvalidArgument("mntx: payout_id is required") } resp, err := g.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetPayoutId())}) if err != nil { return nil, err } return &mntxv1.GetCardPayoutStatusResponse{Payout: payoutFromOperation(resp.GetOperation())}, nil } func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) { return nil, merrors.NotImplemented("mntx: ListGatewayInstances not supported via connector") } func operationFromCardPayout(req *mntxv1.CardPayoutRequest) (*connectorv1.Operation, error) { if req == nil { return nil, merrors.InvalidArgument("mntx: request is required") } params := payoutParamsFromCard(req) money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency()) return &connectorv1.Operation{ Type: connectorv1.OperationType_PAYOUT, IdempotencyKey: strings.TrimSpace(req.GetPayoutId()), Money: money, Params: structFromMap(params), }, nil } func operationFromTokenPayout(req *mntxv1.CardTokenPayoutRequest) (*connectorv1.Operation, error) { if req == nil { return nil, merrors.InvalidArgument("mntx: request is required") } params := payoutParamsFromToken(req) money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency()) return &connectorv1.Operation{ Type: connectorv1.OperationType_PAYOUT, IdempotencyKey: strings.TrimSpace(req.GetPayoutId()), Money: money, Params: structFromMap(params), }, nil } func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} { params := map[string]interface{}{ "payout_id": strings.TrimSpace(req.GetPayoutId()), "project_id": req.GetProjectId(), "customer_id": strings.TrimSpace(req.GetCustomerId()), "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), "customer_last_name": strings.TrimSpace(req.GetCustomerLastName()), "customer_ip": strings.TrimSpace(req.GetCustomerIp()), "customer_zip": strings.TrimSpace(req.GetCustomerZip()), "customer_country": strings.TrimSpace(req.GetCustomerCountry()), "customer_state": strings.TrimSpace(req.GetCustomerState()), "customer_city": strings.TrimSpace(req.GetCustomerCity()), "customer_address": strings.TrimSpace(req.GetCustomerAddress()), "amount_minor": req.GetAmountMinor(), "currency": strings.TrimSpace(req.GetCurrency()), "card_pan": strings.TrimSpace(req.GetCardPan()), "card_exp_year": req.GetCardExpYear(), "card_exp_month": req.GetCardExpMonth(), "card_holder": strings.TrimSpace(req.GetCardHolder()), } if len(req.GetMetadata()) > 0 { params["metadata"] = mapStringToInterface(req.GetMetadata()) } return params } func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} { params := map[string]interface{}{ "payout_id": strings.TrimSpace(req.GetPayoutId()), "project_id": req.GetProjectId(), "customer_id": strings.TrimSpace(req.GetCustomerId()), "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), "customer_last_name": strings.TrimSpace(req.GetCustomerLastName()), "customer_ip": strings.TrimSpace(req.GetCustomerIp()), "customer_zip": strings.TrimSpace(req.GetCustomerZip()), "customer_country": strings.TrimSpace(req.GetCustomerCountry()), "customer_state": strings.TrimSpace(req.GetCustomerState()), "customer_city": strings.TrimSpace(req.GetCustomerCity()), "customer_address": strings.TrimSpace(req.GetCustomerAddress()), "amount_minor": req.GetAmountMinor(), "currency": strings.TrimSpace(req.GetCurrency()), "card_token": strings.TrimSpace(req.GetCardToken()), "card_holder": strings.TrimSpace(req.GetCardHolder()), "masked_pan": strings.TrimSpace(req.GetMaskedPan()), } if len(req.GetMetadata()) > 0 { params["metadata"] = mapStringToInterface(req.GetMetadata()) } return params } func moneyFromMinor(amount int64, currency string) *moneyv1.Money { if amount <= 0 { return nil } dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100)) return &moneyv1.Money{ Amount: dec.StringFixed(2), Currency: strings.ToUpper(strings.TrimSpace(currency)), } } func payoutFromReceipt(payoutID string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState { state := &mntxv1.CardPayoutState{PayoutId: strings.TrimSpace(payoutID)} if receipt == nil { return state } state.Status = payoutStatusFromOperation(receipt.GetStatus()) state.ProviderPaymentId = strings.TrimSpace(receipt.GetProviderRef()) return state } func payoutFromOperation(op *connectorv1.Operation) *mntxv1.CardPayoutState { if op == nil { return nil } state := &mntxv1.CardPayoutState{ PayoutId: strings.TrimSpace(op.GetOperationId()), Status: payoutStatusFromOperation(op.GetStatus()), ProviderPaymentId: strings.TrimSpace(op.GetProviderRef()), } if money := op.GetMoney(); money != nil { state.Currency = strings.TrimSpace(money.GetCurrency()) state.AmountMinor = minorFromMoney(money) } return state } func minorFromMoney(m *moneyv1.Money) int64 { if m == nil { return 0 } amount := strings.TrimSpace(m.GetAmount()) if amount == "" { return 0 } dec, err := decimal.NewFromString(amount) if err != nil { return 0 } return dec.Mul(decimal.NewFromInt(100)).IntPart() } func payoutStatusFromOperation(status connectorv1.OperationStatus) mntxv1.PayoutStatus { switch status { case connectorv1.OperationStatus_CONFIRMED: return mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED case connectorv1.OperationStatus_FAILED: return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED case connectorv1.OperationStatus_PENDING, connectorv1.OperationStatus_SUBMITTED: return mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING default: return mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED } } func connectorError(err *connectorv1.ConnectorError) error { if err == nil { return nil } msg := strings.TrimSpace(err.GetMessage()) switch err.GetCode() { case connectorv1.ErrorCode_INVALID_PARAMS: return merrors.InvalidArgument(msg) case connectorv1.ErrorCode_NOT_FOUND: return merrors.NoData(msg) case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND: return merrors.NotImplemented(msg) case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE: return merrors.Internal(msg) default: return merrors.Internal(msg) } } func structFromMap(data map[string]interface{}) *structpb.Struct { if len(data) == 0 { return nil } result, err := structpb.NewStruct(data) if err != nil { return nil } return result } func mapStringToInterface(input map[string]string) map[string]interface{} { if len(input) == 0 { return nil } out := make(map[string]interface{}, len(input)) for k, v := range input { out[k] = v } return out }