package monetix import ( "bytes" "context" "encoding/json" "io" "net/http" "strings" "time" "github.com/tech/sendico/pkg/merrors" "go.uber.org/zap" ) const ( outcomeAccepted = "accepted" outcomeHTTPError = "http_error" outcomeNetworkError = "network_error" ) // sendCardPayout dispatches a PAN-based payout. func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*CardPayoutSendResult, error) { maskedPAN := MaskPAN(req.Card.PAN) return c.send(ctx, &req, "/v2/payment/card/payout", func() { c.logger.Info("Dispatching Monetix card payout", zap.String("payout_id", req.General.PaymentID), zap.Int64("amount_minor", req.Payment.Amount), zap.String("currency", req.Payment.Currency), zap.String("pan", maskedPAN), ) }, func(r *CardPayoutSendResult) { c.logger.Info("Monetix payout response", zap.String("payout_id", req.General.PaymentID), zap.Bool("accepted", r.Accepted), zap.Int("status_code", r.StatusCode), zap.String("provider_request_id", r.ProviderRequestID), zap.String("error_code", r.ErrorCode), zap.String("error_message", r.ErrorMessage), ) }) } // sendCardTokenPayout dispatches a token-based payout. func (c *Client) sendCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) { return c.send(ctx, &req, "/v2/payment/card/payout/token", func() { c.logger.Info("Dispatching Monetix card token payout", zap.String("payout_id", req.General.PaymentID), zap.Int64("amount_minor", req.Payment.Amount), zap.String("currency", req.Payment.Currency), zap.String("masked_pan", req.Token.MaskedPAN), ) }, func(r *CardPayoutSendResult) { c.logger.Info("Monetix token payout response", zap.String("payout_id", req.General.PaymentID), zap.Bool("accepted", r.Accepted), zap.Int("status_code", r.StatusCode), zap.String("provider_request_id", r.ProviderRequestID), zap.String("error_code", r.ErrorCode), zap.String("error_message", r.ErrorMessage), ) }) } // sendTokenization sends a tokenization request. func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) { if ctx == nil { ctx = context.Background() } if c == nil { return nil, merrors.Internal("monetix client not initialised") } if strings.TrimSpace(c.cfg.SecretKey) == "" { return nil, merrors.Internal("monetix secret key not configured") } if strings.TrimSpace(c.cfg.BaseURL) == "" { return nil, merrors.Internal("monetix base url not configured") } req.General.Signature = "" signature, err := signPayload(req, c.cfg.SecretKey) if err != nil { return nil, merrors.Internal("failed to sign request: " + err.Error()) } req.General.Signature = signature payload, err := json.Marshal(req) if err != nil { return nil, merrors.Internal("failed to marshal request payload: " + err.Error()) } url := strings.TrimRight(c.cfg.BaseURL, "/") + "/v1/tokenize" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return nil, merrors.Internal("failed to build request: " + err.Error()) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json") c.logger.Info("Dispatching Monetix card tokenization", zap.String("request_id", req.General.PaymentID), zap.String("masked_pan", MaskPAN(req.Card.PAN)), ) logRequestDeadline(c.logger, ctx, url) start := time.Now() resp, err := c.client.Do(httpReq) duration := time.Since(start) if err != nil { observeRequest(outcomeNetworkError, duration) fields := []zap.Field{ zap.String("url", url), zap.Error(err), } if ctxErr := ctx.Err(); ctxErr != nil { fields = append(fields, zap.NamedError("ctx_error", ctxErr)) } if deadline, ok := ctx.Deadline(); ok { fields = append(fields, zap.Time("deadline", deadline), zap.Duration("time_until_deadline", time.Until(deadline))) } c.logger.Warn("Monetix tokenization request failed", fields...) return nil, merrors.Internal("monetix tokenization request failed: " + err.Error()) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) outcome := outcomeAccepted if resp.StatusCode < 200 || resp.StatusCode >= 300 { outcome = outcomeHTTPError } observeRequest(outcome, duration) result := &TokenizationResult{ CardPayoutSendResult: CardPayoutSendResult{ Accepted: resp.StatusCode >= 200 && resp.StatusCode < 300, StatusCode: resp.StatusCode, }, } var apiResp APIResponse if len(body) > 0 { if err := json.Unmarshal(body, &apiResp); err != nil { c.logger.Warn("Failed to decode Monetix tokenization response", zap.String("request_id", req.General.PaymentID), zap.Int("status_code", resp.StatusCode), zap.Error(err)) } else { var tokenData struct { Token string `json:"token"` MaskedPAN string `json:"masked_pan"` ExpiryMonth string `json:"expiry_month"` ExpiryYear string `json:"expiry_year"` CardBrand string `json:"card_brand"` } _ = json.Unmarshal(body, &tokenData) result.Token = tokenData.Token result.MaskedPAN = tokenData.MaskedPAN result.ExpiryMonth = tokenData.ExpiryMonth result.ExpiryYear = tokenData.ExpiryYear result.CardBrand = tokenData.CardBrand } } if apiResp.Operation.RequestID != "" { result.ProviderRequestID = apiResp.Operation.RequestID } else if apiResp.RequestID != "" { result.ProviderRequestID = apiResp.RequestID } if !result.Accepted { result.ErrorCode = apiResp.Code if result.ErrorCode == "" { result.ErrorCode = http.StatusText(resp.StatusCode) } result.ErrorMessage = apiResp.Message if result.ErrorMessage == "" { result.ErrorMessage = apiResp.Operation.Message } } c.logger.Info("Monetix tokenization response", zap.String("request_id", req.General.PaymentID), zap.Bool("accepted", result.Accepted), zap.Int("status_code", resp.StatusCode), zap.String("provider_request_id", result.ProviderRequestID), zap.String("error_code", result.ErrorCode), zap.String("error_message", result.ErrorMessage), ) return result, nil } func (c *Client) send(ctx context.Context, req any, path string, dispatchLog func(), responseLog func(*CardPayoutSendResult)) (*CardPayoutSendResult, error) { if ctx == nil { ctx = context.Background() } if c == nil { return nil, merrors.Internal("monetix client not initialised") } if strings.TrimSpace(c.cfg.SecretKey) == "" { return nil, merrors.Internal("monetix secret key not configured") } if strings.TrimSpace(c.cfg.BaseURL) == "" { return nil, merrors.Internal("monetix base url not configured") } setSignature, err := clearSignature(req) if err != nil { return nil, err } signature, err := signPayload(req, c.cfg.SecretKey) if err != nil { return nil, merrors.Internal("failed to sign request: " + err.Error()) } setSignature(signature) payload, err := json.Marshal(req) if err != nil { return nil, merrors.Internal("failed to marshal request payload: " + err.Error()) } url := strings.TrimRight(c.cfg.BaseURL, "/") + path httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return nil, merrors.Internal("failed to build request: " + err.Error()) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json") if dispatchLog != nil { dispatchLog() } logRequestDeadline(c.logger, ctx, url) start := time.Now() resp, err := c.client.Do(httpReq) duration := time.Since(start) if err != nil { observeRequest(outcomeNetworkError, duration) fields := []zap.Field{ zap.String("url", url), zap.Error(err), } if ctxErr := ctx.Err(); ctxErr != nil { fields = append(fields, zap.NamedError("ctx_error", ctxErr)) } if deadline, ok := ctx.Deadline(); ok { fields = append(fields, zap.Time("deadline", deadline), zap.Duration("time_until_deadline", time.Until(deadline))) } c.logger.Warn("Monetix request failed", fields...) return nil, merrors.Internal("monetix request failed: " + err.Error()) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) outcome := outcomeAccepted if resp.StatusCode < 200 || resp.StatusCode >= 300 { outcome = outcomeHTTPError } observeRequest(outcome, duration) result := &CardPayoutSendResult{ Accepted: resp.StatusCode >= 200 && resp.StatusCode < 300, StatusCode: resp.StatusCode, } var apiResp APIResponse if len(body) > 0 { if err := json.Unmarshal(body, &apiResp); err != nil { c.logger.Warn("Failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err)) } } if apiResp.Operation.RequestID != "" { result.ProviderRequestID = apiResp.Operation.RequestID } else if apiResp.RequestID != "" { result.ProviderRequestID = apiResp.RequestID } if !result.Accepted { result.ErrorCode = apiResp.Code if result.ErrorCode == "" { result.ErrorCode = http.StatusText(resp.StatusCode) } result.ErrorMessage = apiResp.Message if result.ErrorMessage == "" { result.ErrorMessage = apiResp.Operation.Message } } if responseLog != nil { responseLog(result) } return result, nil } func clearSignature(req any) (func(string), error) { switch r := req.(type) { case *CardPayoutRequest: r.General.Signature = "" return func(sig string) { r.General.Signature = sig }, nil case *CardTokenPayoutRequest: r.General.Signature = "" return func(sig string) { r.General.Signature = sig }, nil case *CardTokenizeRequest: r.General.Signature = "" return func(sig string) { r.General.Signature = sig }, nil default: return nil, merrors.Internal("unsupported monetix payload type for signing") } } func logRequestDeadline(logger *zap.Logger, ctx context.Context, url string) { if logger == nil { return } if ctx == nil { logger.Info("Monetix request context is nil", zap.String("url", url)) return } deadline, ok := ctx.Deadline() if !ok { logger.Info("Monetix request context has no deadline", zap.String("url", url)) return } logger.Info("Monetix request context deadline", zap.String("url", url), zap.Time("deadline", deadline), zap.Duration("time_until_deadline", time.Until(deadline)), ) }