From 128e5392e159a6bec16d463c7a41358b6a46dfdb Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 27 Feb 2026 16:15:46 +0100 Subject: [PATCH] year normalization --- .../mntx/internal/service/gateway/service.go | 1 + .../mntx/internal/service/monetix/payloads.go | 2 + .../mntx/internal/service/monetix/sender.go | 42 ++- .../internal/service/monetix/sender_test.go | 333 ++++++++++++++++++ 4 files changed, 375 insertions(+), 3 deletions(-) diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index 245266aa..0ddaece1 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -156,6 +156,7 @@ func (s *Service) startDiscoveryAnnouncer() { Operations: discovery.CardPayoutRailGatewayOperations(), InvokeURI: s.invokeURI, Version: appversion.Create().Short(), + InstanceID: discovery.InstanceID(), } if s.gatewayDescriptor != nil { if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" { diff --git a/api/gateway/mntx/internal/service/monetix/payloads.go b/api/gateway/mntx/internal/service/monetix/payloads.go index b44bc200..4b70338b 100644 --- a/api/gateway/mntx/internal/service/monetix/payloads.go +++ b/api/gateway/mntx/internal/service/monetix/payloads.go @@ -69,6 +69,7 @@ type CardTokenizeRequest struct { type CardPayoutSendResult struct { Accepted bool ProviderRequestID string + ProviderStatus string StatusCode int ErrorCode string ErrorMessage string @@ -85,6 +86,7 @@ type TokenizationResult struct { type APIResponse struct { RequestID string `json:"request_id"` + Status string `json:"status"` Message string `json:"message"` Code string `json:"code"` Operation struct { diff --git a/api/gateway/mntx/internal/service/monetix/sender.go b/api/gateway/mntx/internal/service/monetix/sender.go index 35f87995..b89b0b31 100644 --- a/api/gateway/mntx/internal/service/monetix/sender.go +++ b/api/gateway/mntx/internal/service/monetix/sender.go @@ -37,6 +37,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca zap.String("payout_id", req.General.PaymentID), zap.Bool("accepted", r.Accepted), zap.Int("status_code", r.StatusCode), + zap.String("provider_status", r.ProviderStatus), zap.String("provider_request_id", r.ProviderRequestID), zap.String("error_code", r.ErrorCode), zap.String("error_message", r.ErrorMessage), @@ -60,6 +61,7 @@ func (c *Client) sendCardTokenPayout(ctx context.Context, req CardTokenPayoutReq zap.String("payout_id", req.General.PaymentID), zap.Bool("accepted", r.Accepted), zap.Int("status_code", r.StatusCode), + zap.String("provider_status", r.ProviderStatus), zap.String("provider_request_id", r.ProviderRequestID), zap.String("error_code", r.ErrorCode), zap.String("error_message", r.ErrorMessage), @@ -81,6 +83,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) if strings.TrimSpace(c.cfg.BaseURL) == "" { return nil, merrors.Internal("monetix base url not configured") } + normalizeRequestExpiryYear(&req) req.General.Signature = "" signature, err := signPayload(req, c.cfg.SecretKey) @@ -168,6 +171,10 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) } else if apiResp.RequestID != "" { result.ProviderRequestID = apiResp.RequestID } + result.ProviderStatus = strings.TrimSpace(apiResp.Status) + if result.ProviderStatus == "" { + result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status) + } if !result.Accepted { result.ErrorCode = apiResp.Code @@ -184,6 +191,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) zap.String("request_id", req.General.PaymentID), zap.Bool("accepted", result.Accepted), zap.Int("status_code", resp.StatusCode), + zap.String("provider_status", result.ProviderStatus), zap.String("provider_request_id", result.ProviderRequestID), zap.String("error_code", result.ErrorCode), zap.String("error_message", result.ErrorMessage), @@ -205,6 +213,7 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun if strings.TrimSpace(c.cfg.BaseURL) == "" { return nil, merrors.Internal("monetix base url not configured") } + normalizeRequestExpiryYear(req) setSignature, err := clearSignature(req) if err != nil { @@ -254,12 +263,16 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + observeRequest(outcomeNetworkError, duration) + c.logger.Warn("Failed to read Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err)) + return nil, merrors.Internal("failed to read monetix response: " + err.Error()) + } outcome := outcomeAccepted if resp.StatusCode < 200 || resp.StatusCode >= 300 { outcome = outcomeHTTPError } - observeRequest(outcome, duration) result := &CardPayoutSendResult{ Accepted: resp.StatusCode >= 200 && resp.StatusCode < 300, @@ -269,7 +282,9 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun var apiResp APIResponse if len(body) > 0 { if err := json.Unmarshal(body, &apiResp); err != nil { + observeRequest(outcomeHTTPError, duration) c.logger.Warn("Failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err)) + return nil, merrors.Internal("failed to decode monetix response: " + err.Error()) } } @@ -278,6 +293,10 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun } else if apiResp.RequestID != "" { result.ProviderRequestID = apiResp.RequestID } + result.ProviderStatus = strings.TrimSpace(apiResp.Status) + if result.ProviderStatus == "" { + result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status) + } if !result.Accepted { result.ErrorCode = apiResp.Code @@ -293,10 +312,27 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun if responseLog != nil { responseLog(result) } + observeRequest(outcome, duration) return result, nil } +func normalizeExpiryYear(year int) int { + if year > 0 && year < 100 { + return year + 2000 + } + return year +} + +func normalizeRequestExpiryYear(req any) { + switch r := req.(type) { + case *CardPayoutRequest: + r.Card.Year = normalizeExpiryYear(r.Card.Year) + case *CardTokenizeRequest: + r.Card.Year = normalizeExpiryYear(r.Card.Year) + } +} + func clearSignature(req any) (func(string), error) { switch r := req.(type) { case *CardPayoutRequest: @@ -323,7 +359,7 @@ func logRequestDeadline(logger mlogger.Logger, ctx context.Context, url string) logger.Info("Monetix request context has no deadline", zap.String("url", url)) return } - logger.Info("Monetix request context deadline", + logger.Debug("Monetix request context deadline", zap.String("url", url), zap.Time("deadline", deadline), zap.Duration("time_until_deadline", time.Until(deadline)), diff --git a/api/gateway/mntx/internal/service/monetix/sender_test.go b/api/gateway/mntx/internal/service/monetix/sender_test.go index 6e26aeaf..236de6ab 100644 --- a/api/gateway/mntx/internal/service/monetix/sender_test.go +++ b/api/gateway/mntx/internal/service/monetix/sender_test.go @@ -4,11 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "strings" "testing" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" "go.uber.org/zap" ) @@ -82,6 +85,51 @@ func TestSendCardPayout_SignsPayload(t *testing.T) { } } +func TestSendCardPayout_NormalizesTwoDigitYearBeforeSend(t *testing.T) { + var captured CardPayoutRequest + + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + payload, _ := json.Marshal(APIResponse{}) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(payload)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + cfg := Config{ + BaseURL: "https://monetix.test", + SecretKey: "secret", + } + client := NewClient(cfg, httpClient, zap.NewNop()) + + req := CardPayoutRequest{ + General: General{ProjectID: 1, PaymentID: "payout-1"}, + Customer: Customer{ + ID: "cust-1", + FirstName: "Jane", + LastName: "Doe", + IP: "203.0.113.10", + }, + Payment: Payment{Amount: 1000, Currency: "RUB"}, + Card: Card{PAN: "4111111111111111", Year: 30, Month: 12, CardHolder: "JANE DOE"}, + } + + _, err := client.CreateCardPayout(context.Background(), req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if captured.Card.Year != 2030 { + t.Fatalf("expected normalized year 2030, got %d", captured.Card.Year) + } +} + func TestSendCardPayout_HTTPError(t *testing.T) { httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { @@ -126,3 +174,288 @@ func TestSendCardPayout_HTTPError(t *testing.T) { t.Fatalf("expected error message denied, got %q", result.ErrorMessage) } } + +type errorReadCloser struct { + err error +} + +func (e errorReadCloser) Read(_ []byte) (int, error) { + return 0, e.err +} + +func (e errorReadCloser) Close() error { + return nil +} + +type countingReadCloser struct { + data []byte + offset int + readBytes int + chunkSize int +} + +func (c *countingReadCloser) Read(p []byte) (int, error) { + if c.offset >= len(c.data) { + return 0, io.EOF + } + n := c.chunkSize + if n <= 0 || n > len(p) { + n = len(p) + } + remaining := len(c.data) - c.offset + if n > remaining { + n = remaining + } + copy(p[:n], c.data[c.offset:c.offset+n]) + c.offset += n + c.readBytes += n + return n, nil +} + +func (c *countingReadCloser) Close() error { + return nil +} + +func counterValue(t *testing.T, counter prometheus.Counter) float64 { + t.Helper() + metric := &dto.Metric{} + if err := counter.Write(metric); err != nil { + t.Fatalf("failed to read counter value: %v", err) + } + return metric.GetCounter().GetValue() +} + +func TestSendCardPayout_ReadBodyError(t *testing.T) { + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: errorReadCloser{err: errors.New("read failed")}, + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + cfg := Config{ + BaseURL: "https://monetix.test", + SecretKey: "secret", + } + client := NewClient(cfg, httpClient, zap.NewNop()) + + req := CardPayoutRequest{ + General: General{ProjectID: 1, PaymentID: "payout-1"}, + Customer: Customer{ + ID: "cust-1", + FirstName: "Jane", + LastName: "Doe", + IP: "203.0.113.10", + }, + Payment: Payment{Amount: 1000, Currency: "RUB"}, + Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, + } + + _, err := client.CreateCardPayout(context.Background(), req) + if err == nil { + t.Fatalf("expected read error") + } + if !strings.Contains(err.Error(), "failed to read monetix response") { + t.Fatalf("expected wrapped read error, got %v", err) + } +} + +func TestSendCardPayout_DecodeErrorTrackedAsHTTPError(t *testing.T) { + initMetrics() + beforeAccepted := counterValue(t, cardPayoutRequests.WithLabelValues(outcomeAccepted)) + beforeHTTPError := counterValue(t, cardPayoutRequests.WithLabelValues(outcomeHTTPError)) + + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("{")), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + cfg := Config{ + BaseURL: "https://monetix.test", + SecretKey: "secret", + } + client := NewClient(cfg, httpClient, zap.NewNop()) + + req := CardPayoutRequest{ + General: General{ProjectID: 1, PaymentID: "payout-1"}, + Customer: Customer{ + ID: "cust-1", + FirstName: "Jane", + LastName: "Doe", + IP: "203.0.113.10", + }, + Payment: Payment{Amount: 1000, Currency: "RUB"}, + Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, + } + + _, err := client.CreateCardPayout(context.Background(), req) + if err == nil { + t.Fatalf("expected decode error") + } + if !strings.Contains(err.Error(), "failed to decode monetix response") { + t.Fatalf("expected wrapped decode error, got %v", err) + } + + afterAccepted := counterValue(t, cardPayoutRequests.WithLabelValues(outcomeAccepted)) + afterHTTPError := counterValue(t, cardPayoutRequests.WithLabelValues(outcomeHTTPError)) + + if afterAccepted != beforeAccepted { + t.Fatalf("accepted counter changed unexpectedly: before=%v after=%v", beforeAccepted, afterAccepted) + } + if afterHTTPError != beforeHTTPError+1 { + t.Fatalf("http_error counter not incremented: before=%v after=%v", beforeHTTPError, afterHTTPError) + } +} + +func TestSendCardPayout_ReadsFullMonetixRegistrationBody(t *testing.T) { + rawBody := `{"status":"waiting","request_id":"req-123","project_id":10,"payment_id":"pay-1"}` + body := &countingReadCloser{ + data: []byte(rawBody), + chunkSize: 7, + } + + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: body, + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + cfg := Config{ + BaseURL: "https://monetix.test", + SecretKey: "secret", + } + client := NewClient(cfg, httpClient, zap.NewNop()) + + req := CardPayoutRequest{ + General: General{ProjectID: 1, PaymentID: "payout-1"}, + Customer: Customer{ + ID: "cust-1", + FirstName: "Jane", + LastName: "Doe", + IP: "203.0.113.10", + }, + Payment: Payment{Amount: 1000, Currency: "RUB"}, + Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, + } + + result, err := client.CreateCardPayout(context.Background(), req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !result.Accepted { + t.Fatalf("expected accepted response") + } + if result.ProviderRequestID != "req-123" { + t.Fatalf("expected provider request id req-123, got %q", result.ProviderRequestID) + } + if result.ProviderStatus != "waiting" { + t.Fatalf("expected provider status waiting, got %q", result.ProviderStatus) + } + if body.readBytes != len(rawBody) { + t.Fatalf("expected full body read (%d), got %d", len(rawBody), body.readBytes) + } +} + +func TestSendCardPayout_ProviderStatusFallsBackToOperationStatus(t *testing.T) { + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + body := `{"operation":{"request_id":"req-1","status":"processing"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + cfg := Config{ + BaseURL: "https://monetix.test", + SecretKey: "secret", + } + client := NewClient(cfg, httpClient, zap.NewNop()) + + req := CardPayoutRequest{ + General: General{ProjectID: 1, PaymentID: "payout-1"}, + Customer: Customer{ + ID: "cust-1", + FirstName: "Jane", + LastName: "Doe", + IP: "203.0.113.10", + }, + Payment: Payment{Amount: 1000, Currency: "RUB"}, + Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, + } + + result, err := client.CreateCardPayout(context.Background(), req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result.ProviderStatus != "processing" { + t.Fatalf("expected provider status processing, got %q", result.ProviderStatus) + } +} + +func TestSendCardTokenization_NormalizesTwoDigitYearBeforeSend(t *testing.T) { + var captured CardTokenizeRequest + + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/v1/tokenize" { + t.Fatalf("expected tokenization path, got %q", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + payload, _ := json.Marshal(APIResponse{}) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(payload)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + cfg := Config{ + BaseURL: "https://monetix.test", + SecretKey: "secret", + } + client := NewClient(cfg, httpClient, zap.NewNop()) + + req := CardTokenizeRequest{ + General: General{ProjectID: 1, PaymentID: "tokenize-1"}, + Customer: Customer{ + ID: "cust-1", + FirstName: "Jane", + LastName: "Doe", + IP: "203.0.113.10", + }, + Card: CardTokenize{ + PAN: "4111111111111111", + Year: 30, + Month: 12, + CardHolder: "JANE DOE", + CVV: "123", + }, + } + + _, err := client.CreateCardTokenization(context.Background(), req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if captured.Card.Year != 2030 { + t.Fatalf("expected normalized year 2030, got %d", captured.Card.Year) + } +} -- 2.49.1