Files
sendico/api/gateway/mntx/client/client.go
2026-01-05 01:22:47 +01:00

331 lines
12 KiB
Go

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
}