diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go index 1a8820a..57b2c7a 100644 --- a/api/gateway/mntx/internal/server/internal/serverimp.go +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -95,22 +95,49 @@ func (i *Imp) Shutdown() { } func (i *Imp) Start() error { + i.logger.Info("Starting Monetix gateway", zap.String("config_file", i.file), zap.Bool("debug", i.debug)) + cfg, err := i.loadConfig() if err != nil { return err } i.config = cfg + i.logger.Info("Configuration loaded", + zap.String("grpc_address", cfg.GRPC.Address), + zap.String("metrics_address", cfg.Metrics.Address), + ) + monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix) if err != nil { + i.logger.Error("Failed to resolve Monetix configuration", zap.Error(err)) return err } callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback) if err != nil { + i.logger.Error("Failed to resolve callback configuration", zap.Error(err)) return err } + i.logger.Info("Monetix configuration resolved", + zap.Bool("base_url_set", strings.TrimSpace(monetixCfg.BaseURL) != ""), + zap.Int64("project_id", monetixCfg.ProjectID), + zap.Bool("secret_key_set", strings.TrimSpace(monetixCfg.SecretKey) != ""), + zap.Int("allowed_currencies", len(monetixCfg.AllowedCurrencies)), + zap.Bool("require_customer_address", monetixCfg.RequireCustomerAddress), + zap.Duration("request_timeout", monetixCfg.RequestTimeout), + zap.String("status_success", monetixCfg.SuccessStatus()), + zap.String("status_processing", monetixCfg.ProcessingStatus()), + ) + + i.logger.Info("Callback configuration resolved", + zap.String("address", callbackCfg.Address), + zap.String("path", callbackCfg.Path), + zap.Int("allowed_cidrs", len(callbackCfg.AllowedCIDRs)), + zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes), + ) + serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) { svc := mntxservice.NewService(logger, mntxservice.WithProducer(producer), @@ -137,7 +164,7 @@ func (i *Imp) Start() error { func (i *Imp) loadConfig() (*config, error) { data, err := os.ReadFile(i.file) if err != nil { - i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) return nil, err } @@ -145,7 +172,7 @@ func (i *Imp) loadConfig() (*config, error) { Config: &grpcapp.Config{}, } if err := yaml.Unmarshal(data, cfg); err != nil { - i.logger.Error("failed to parse configuration", zap.Error(err)) + i.logger.Error("Failed to parse configuration", zap.Error(err)) return nil, err } @@ -245,7 +272,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig, } _, block, err := net.ParseCIDR(clean) if err != nil { - i.logger.Warn("invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err)) + i.logger.Warn("Invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err)) continue } cidrs = append(cidrs, block) @@ -270,20 +297,36 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt router := chi.NewRouter() router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) { + log := i.logger.Named("callback_http") + log.Debug("Callback request received", + zap.String("remote_addr", strings.TrimSpace(r.RemoteAddr)), + zap.String("path", r.URL.Path), + zap.String("method", r.Method), + ) + if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) { + ip := clientIPFromRequest(r) + remoteIP := "" + if ip != nil { + remoteIP = ip.String() + } + log.Warn("Callback rejected by CIDR allowlist", zap.String("remote_ip", remoteIP)) http.Error(w, "forbidden", http.StatusForbidden) return } body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes)) if err != nil { + log.Warn("Callback body read failed", zap.Error(err)) http.Error(w, "failed to read body", http.StatusBadRequest) return } status, err := svc.ProcessMonetixCallback(r.Context(), body) if err != nil { + log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status)) http.Error(w, err.Error(), status) return } + log.Debug("Callback processed", zap.Int("status", status)) w.WriteHeader(status) }) @@ -301,7 +344,7 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt go func() { if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { - i.logger.Error("Monetix callback server stopped with error", zap.Error(err)) + i.logger.Warn("Monetix callback server stopped with error", zap.Error(err)) } }() diff --git a/api/gateway/mntx/internal/server/internal/serverimp_test.go b/api/gateway/mntx/internal/server/internal/serverimp_test.go new file mode 100644 index 0000000..5556cb6 --- /dev/null +++ b/api/gateway/mntx/internal/server/internal/serverimp_test.go @@ -0,0 +1,52 @@ +package serverimp + +import ( + "net" + "net/http" + "testing" +) + +func TestClientIPFromRequest(t *testing.T) { + req := &http.Request{ + Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}}, + RemoteAddr: "9.8.7.6:1234", + } + ip := clientIPFromRequest(req) + if ip == nil || ip.String() != "1.2.3.4" { + t.Fatalf("expected forwarded ip, got %v", ip) + } + + req = &http.Request{RemoteAddr: "9.8.7.6:1234"} + ip = clientIPFromRequest(req) + if ip == nil || ip.String() != "9.8.7.6" { + t.Fatalf("expected remote addr ip, got %v", ip) + } + + req = &http.Request{RemoteAddr: "invalid"} + ip = clientIPFromRequest(req) + if ip != nil { + t.Fatalf("expected nil ip, got %v", ip) + } +} + +func TestClientAllowed(t *testing.T) { + _, cidr, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("failed to parse cidr: %v", err) + } + + allowedReq := &http.Request{RemoteAddr: "10.1.2.3:1234"} + if !clientAllowed(allowedReq, []*net.IPNet{cidr}) { + t.Fatalf("expected allowed request") + } + + deniedReq := &http.Request{RemoteAddr: "8.8.8.8:1234"} + if clientAllowed(deniedReq, []*net.IPNet{cidr}) { + t.Fatalf("expected denied request") + } + + openReq := &http.Request{RemoteAddr: "8.8.8.8:1234"} + if !clientAllowed(openReq, nil) { + t.Fatalf("expected allow when no cidrs are configured") + } +} diff --git a/api/gateway/mntx/internal/service/gateway/callback.go b/api/gateway/mntx/internal/service/gateway/callback.go index c09da3e..692f328 100644 --- a/api/gateway/mntx/internal/service/gateway/callback.go +++ b/api/gateway/mntx/internal/service/gateway/callback.go @@ -10,6 +10,7 @@ import ( clockpkg "github.com/tech/sendico/pkg/clock" "github.com/tech/sendico/pkg/merrors" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -66,9 +67,12 @@ type monetixCallback struct { // ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state. func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) { + log := s.logger.Named("callback") if s.card == nil { + log.Warn("Card payout processor not initialised") return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") } + log.Debug("Callback processing requested", zap.Int("payload_bytes", len(payload))) return s.card.ProcessCallback(ctx, payload) } diff --git a/api/gateway/mntx/internal/service/gateway/callback_test.go b/api/gateway/mntx/internal/service/gateway/callback_test.go new file mode 100644 index 0000000..6290030 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/callback_test.go @@ -0,0 +1,130 @@ +package gateway + +import ( + "testing" + "time" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +type fixedClock struct { + now time.Time +} + +func (f fixedClock) Now() time.Time { + return f.now +} + +func baseCallback() monetixCallback { + cb := monetixCallback{ + ProjectID: 42, + } + cb.Payment.ID = "payout-1" + cb.Payment.Status = "success" + cb.Payment.Sum.Amount = 5000 + cb.Payment.Sum.Currency = "usd" + cb.Customer.ID = "cust-1" + cb.Operation.Status = "success" + cb.Operation.Code = "" + cb.Operation.Message = "ok" + cb.Operation.RequestID = "req-1" + cb.Operation.Provider.PaymentID = "prov-1" + return cb +} + +func TestMapCallbackToState_StatusMapping(t *testing.T) { + now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + cfg := monetix.DefaultConfig() + + cases := []struct { + name string + paymentStatus string + operationStatus string + code string + expectedStatus mntxv1.PayoutStatus + expectedOutcome string + }{ + { + name: "success", + paymentStatus: "success", + operationStatus: "success", + code: "0", + expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED, + expectedOutcome: monetix.OutcomeSuccess, + }, + { + name: "processing", + paymentStatus: "processing", + operationStatus: "success", + code: "", + expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, + expectedOutcome: monetix.OutcomeProcessing, + }, + { + name: "decline", + paymentStatus: "failed", + operationStatus: "failed", + code: "1", + expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED, + expectedOutcome: monetix.OutcomeDecline, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cb := baseCallback() + cb.Payment.Status = tc.paymentStatus + cb.Operation.Status = tc.operationStatus + cb.Operation.Code = tc.code + + state, outcome := mapCallbackToState(fixedClock{now: now}, cfg, cb) + if state.Status != tc.expectedStatus { + t.Fatalf("expected status %v, got %v", tc.expectedStatus, state.Status) + } + if outcome != tc.expectedOutcome { + t.Fatalf("expected outcome %q, got %q", tc.expectedOutcome, outcome) + } + if state.Currency != "USD" { + t.Fatalf("expected currency USD, got %q", state.Currency) + } + if !state.UpdatedAt.AsTime().Equal(now) { + t.Fatalf("expected updated_at %v, got %v", now, state.UpdatedAt.AsTime()) + } + }) + } +} + +func TestFallbackProviderPaymentID(t *testing.T) { + cb := baseCallback() + if got := fallbackProviderPaymentID(cb); got != "prov-1" { + t.Fatalf("expected provider payment id, got %q", got) + } + cb.Operation.Provider.PaymentID = "" + if got := fallbackProviderPaymentID(cb); got != "req-1" { + t.Fatalf("expected request id fallback, got %q", got) + } + cb.Operation.RequestID = "" + if got := fallbackProviderPaymentID(cb); got != "payout-1" { + t.Fatalf("expected payment id fallback, got %q", got) + } +} + +func TestVerifyCallbackSignature(t *testing.T) { + secret := "secret" + cb := baseCallback() + + sig, err := monetix.SignPayload(cb, secret) + if err != nil { + t.Fatalf("failed to sign payload: %v", err) + } + cb.Signature = sig + if err := verifyCallbackSignature(cb, secret); err != nil { + t.Fatalf("expected valid signature, got %v", err) + } + + cb.Signature = "invalid" + if err := verifyCallbackSignature(cb, secret); err == nil { + t.Fatalf("expected signature mismatch error") + } +} diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go b/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go index 55aebdf..80b56ff 100644 --- a/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go +++ b/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mservice" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" "google.golang.org/protobuf/proto" ) @@ -17,14 +18,24 @@ func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRe } func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] { + log := s.logger.Named("card_payout") + log.Info("Create card payout request received", + zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + ) if s.card == nil { + log.Warn("Card payout processor not initialised") return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) } resp, err := s.card.Submit(ctx, req) if err != nil { + log.Warn("Card payout submission failed", zap.Error(err)) return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err) } + log.Info("Card payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted())) return gsresponse.Success(resp) } @@ -33,14 +44,24 @@ func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTok } func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] { + log := s.logger.Named("card_token_payout") + log.Info("Create card token payout request received", + zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + ) if s.card == nil { + log.Warn("Card payout processor not initialised") return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) } resp, err := s.card.SubmitToken(ctx, req) if err != nil { + log.Warn("Card token payout submission failed", zap.Error(err)) return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err) } + log.Info("Card token payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted())) return gsresponse.Success(resp) } @@ -49,14 +70,22 @@ func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeR } func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] { + log := s.logger.Named("card_tokenize") + log.Info("Create card token request received", + zap.String("request_id", strings.TrimSpace(req.GetRequestId())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + ) if s.card == nil { + log.Warn("Card payout processor not initialised") return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) } resp, err := s.card.Tokenize(ctx, req) if err != nil { + log.Warn("Card tokenization failed", zap.Error(err)) return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err) } + log.Info("Card tokenization completed", zap.String("request_id", resp.GetRequestId()), zap.Bool("success", resp.GetSuccess())) return gsresponse.Success(resp) } @@ -65,14 +94,19 @@ func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPa } func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] { + log := s.logger.Named("card_payout_status") + log.Info("Get card payout status request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId()))) if s.card == nil { + log.Warn("Card payout processor not initialised") return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) } state, err := s.card.Status(context.Background(), req.GetPayoutId()) if err != nil { + log.Warn("Card payout status lookup failed", zap.Error(err)) return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err) } + log.Info("Card payout status retrieved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String())) return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state}) } diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go b/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go new file mode 100644 index 0000000..642b128 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go @@ -0,0 +1,103 @@ +package gateway + +import ( + "testing" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func TestValidateCardPayoutRequest_Valid(t *testing.T) { + cfg := testMonetixConfig() + req := validCardPayoutRequest() + if err := validateCardPayoutRequest(req, cfg); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateCardPayoutRequest_Errors(t *testing.T) { + baseCfg := testMonetixConfig() + cases := []struct { + name string + mutate func(*mntxv1.CardPayoutRequest) + config func(monetix.Config) monetix.Config + expected string + }{ + { + name: "missing_payout_id", + mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" }, + expected: "missing_payout_id", + }, + { + name: "missing_customer_id", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerId = "" }, + expected: "missing_customer_id", + }, + { + name: "missing_customer_ip", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerIp = "" }, + expected: "missing_customer_ip", + }, + { + name: "invalid_amount", + mutate: func(r *mntxv1.CardPayoutRequest) { r.AmountMinor = 0 }, + expected: "invalid_amount", + }, + { + name: "missing_currency", + mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "" }, + expected: "missing_currency", + }, + { + name: "unsupported_currency", + mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "EUR" }, + config: func(cfg monetix.Config) monetix.Config { + cfg.AllowedCurrencies = []string{"USD"} + return cfg + }, + expected: "unsupported_currency", + }, + { + name: "missing_card_pan", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "" }, + expected: "missing_card_pan", + }, + { + name: "missing_card_holder", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CardHolder = "" }, + expected: "missing_card_holder", + }, + { + name: "invalid_expiry_month", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpMonth = 13 }, + expected: "invalid_expiry_month", + }, + { + name: "invalid_expiry_year", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpYear = 0 }, + expected: "invalid_expiry_year", + }, + { + name: "missing_customer_country_when_required", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerCountry = "" }, + config: func(cfg monetix.Config) monetix.Config { + cfg.RequireCustomerAddress = true + return cfg + }, + expected: "missing_customer_country", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := validCardPayoutRequest() + tc.mutate(req) + cfg := baseCfg + if tc.config != nil { + cfg = tc.config(cfg) + } + err := validateCardPayoutRequest(req, cfg) + requireReason(t, err, tc.expected) + }) + } +} diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index 8640ede..398c8f7 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -45,14 +45,20 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout if p == nil { return nil, merrors.Internal("card payout processor not initialised") } + p.logger.Info("Submitting card payout", + zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + ) if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" { - p.logger.Warn("monetix configuration is incomplete for payout submission") + p.logger.Warn("Monetix configuration is incomplete for payout submission") return nil, merrors.Internal("monetix configuration is incomplete") } req = sanitizeCardPayoutRequest(req) if err := validateCardPayoutRequest(req, p.config); err != nil { - p.logger.Warn("card payout validation failed", + p.logger.Warn("Card payout validation failed", zap.String("payout_id", req.GetPayoutId()), zap.String("customer_id", req.GetCustomerId()), zap.Error(err), @@ -65,7 +71,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout projectID = p.config.ProjectID } if projectID == 0 { - p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId())) + p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId())) return nil, merrors.Internal("monetix project_id is not configured") } @@ -95,7 +101,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout state.ProviderMessage = err.Error() state.UpdatedAt = timestamppb.New(p.clock.Now()) p.store.Save(state) - p.logger.Warn("monetix payout submission failed", + p.logger.Warn("Monetix payout submission failed", zap.String("payout_id", req.GetPayoutId()), zap.String("customer_id", req.GetCustomerId()), zap.Error(err), @@ -122,6 +128,13 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout ErrorMessage: result.ErrorMessage, } + p.logger.Info("Card payout submission stored", + zap.String("payout_id", state.GetPayoutId()), + zap.String("status", state.GetStatus().String()), + zap.Bool("accepted", result.Accepted), + zap.String("provider_request_id", result.ProviderRequestID), + ) + return resp, nil } @@ -129,14 +142,20 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT if p == nil { return nil, merrors.Internal("card payout processor not initialised") } + p.logger.Info("Submitting card token payout", + zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + ) if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" { - p.logger.Warn("monetix configuration is incomplete for token payout submission") + p.logger.Warn("Monetix configuration is incomplete for token payout submission") return nil, merrors.Internal("monetix configuration is incomplete") } req = sanitizeCardTokenPayoutRequest(req) if err := validateCardTokenPayoutRequest(req, p.config); err != nil { - p.logger.Warn("card token payout validation failed", + p.logger.Warn("Card token payout validation failed", zap.String("payout_id", req.GetPayoutId()), zap.String("customer_id", req.GetCustomerId()), zap.Error(err), @@ -149,7 +168,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT projectID = p.config.ProjectID } if projectID == 0 { - p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId())) + p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId())) return nil, merrors.Internal("monetix project_id is not configured") } @@ -179,7 +198,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT state.ProviderMessage = err.Error() state.UpdatedAt = timestamppb.New(p.clock.Now()) p.store.Save(state) - p.logger.Warn("monetix token payout submission failed", + p.logger.Warn("Monetix token payout submission failed", zap.String("payout_id", req.GetPayoutId()), zap.String("customer_id", req.GetCustomerId()), zap.Error(err), @@ -206,6 +225,13 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT ErrorMessage: result.ErrorMessage, } + p.logger.Info("Card token payout submission stored", + zap.String("payout_id", state.GetPayoutId()), + zap.String("status", state.GetStatus().String()), + zap.Bool("accepted", result.Accepted), + zap.String("provider_request_id", result.ProviderRequestID), + ) + return resp, nil } @@ -213,9 +239,13 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke if p == nil { return nil, merrors.Internal("card payout processor not initialised") } + p.logger.Info("Submitting card tokenization", + zap.String("request_id", strings.TrimSpace(req.GetRequestId())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + ) cardInput, err := validateCardTokenizeRequest(req, p.config) if err != nil { - p.logger.Warn("card tokenization validation failed", + p.logger.Warn("Card tokenization validation failed", zap.String("request_id", req.GetRequestId()), zap.String("customer_id", req.GetCustomerId()), zap.Error(err), @@ -228,7 +258,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke projectID = p.config.ProjectID } if projectID == 0 { - p.logger.Warn("monetix project_id is not configured", zap.String("request_id", req.GetRequestId())) + p.logger.Warn("Monetix project_id is not configured", zap.String("request_id", req.GetRequestId())) return nil, merrors.Internal("monetix project_id is not configured") } @@ -238,7 +268,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke apiReq := buildCardTokenizeRequest(projectID, req, cardInput) result, err := client.CreateCardTokenization(ctx, apiReq) if err != nil { - p.logger.Warn("monetix tokenization request failed", + p.logger.Warn("Monetix tokenization request failed", zap.String("request_id", req.GetRequestId()), zap.String("customer_id", req.GetCustomerId()), zap.Error(err), @@ -258,6 +288,12 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke resp.ExpiryYear = result.ExpiryYear resp.CardBrand = result.CardBrand + p.logger.Info("Card tokenization completed", + zap.String("request_id", resp.GetRequestId()), + zap.Bool("success", resp.GetSuccess()), + zap.String("provider_request_id", result.ProviderRequestID), + ) + return resp, nil } @@ -267,16 +303,18 @@ func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv } id := strings.TrimSpace(payoutID) + p.logger.Info("Card payout status requested", zap.String("payout_id", id)) if id == "" { - p.logger.Warn("payout status requested with empty payout_id") + p.logger.Warn("Payout status requested with empty payout_id") return nil, merrors.InvalidArgument("payout_id is required", "payout_id") } state, ok := p.store.Get(id) if !ok || state == nil { - p.logger.Warn("payout status not found", zap.String("payout_id", id)) + p.logger.Warn("Payout status not found", zap.String("payout_id", id)) return nil, merrors.NoData("payout not found") } + p.logger.Info("Card payout status resolved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String())) return state, nil } @@ -284,18 +322,19 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt if p == nil { return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") } + p.logger.Debug("Processing Monetix callback", zap.Int("payload_bytes", len(payload))) if len(payload) == 0 { - p.logger.Warn("received empty Monetix callback payload") + p.logger.Warn("Received empty Monetix callback payload") return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty") } if strings.TrimSpace(p.config.SecretKey) == "" { - p.logger.Warn("monetix secret key is not configured; cannot verify callback") + p.logger.Warn("Monetix secret key is not configured; cannot verify callback") return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured") } var cb monetixCallback if err := json.Unmarshal(payload, &cb); err != nil { - p.logger.Warn("failed to unmarshal Monetix callback", zap.Error(err)) + p.logger.Warn("Failed to unmarshal Monetix callback", zap.Error(err)) return http.StatusBadRequest, err } @@ -318,7 +357,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt p.emitCardPayoutEvent(state) monetix.ObserveCallback(statusLabel) - p.logger.Info("Monetix payout callback processed", + p.logger.Debug("Monetix payout callback processed", zap.String("payout_id", state.GetPayoutId()), zap.String("status", statusLabel), zap.String("provider_code", state.GetProviderCode()), @@ -337,16 +376,16 @@ func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState) event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state} payload, err := protojson.Marshal(event) if err != nil { - p.logger.Warn("failed to marshal payout callback event", zap.Error(err)) + p.logger.Warn("Failed to marshal payout callback event", zap.Error(err)) return } env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated)) if _, err := env.Wrap(payload); err != nil { - p.logger.Warn("failed to wrap payout callback event payload", zap.Error(err)) + p.logger.Warn("Failed to wrap payout callback event payload", zap.Error(err)) return } if err := p.producer.SendMessage(env); err != nil { - p.logger.Warn("failed to publish payout callback event", zap.Error(err)) + p.logger.Warn("Failed to publish payout callback event", zap.Error(err)) } } diff --git a/api/gateway/mntx/internal/service/gateway/card_processor_test.go b/api/gateway/mntx/internal/service/gateway/card_processor_test.go new file mode 100644 index 0000000..d4c73e6 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_processor_test.go @@ -0,0 +1,149 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + "time" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +type staticClock struct { + now time.Time +} + +func (s staticClock) Now() time.Time { + return s.now +} + +func TestCardPayoutProcessor_Submit_Success(t *testing.T) { + cfg := monetix.Config{ + BaseURL: "https://monetix.test", + SecretKey: "secret", + ProjectID: 99, + AllowedCurrencies: []string{"RUB"}, + } + + existingCreated := timestamppb.New(time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC)) + store := newCardPayoutStore() + store.Save(&mntxv1.CardPayoutState{ + PayoutId: "payout-1", + CreatedAt: existingCreated, + }) + + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + resp := monetix.APIResponse{} + resp.Operation.RequestID = "req-123" + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, store, httpClient, nil) + + req := validCardPayoutRequest() + req.ProjectId = 0 + + resp, err := processor.Submit(context.Background(), req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !resp.GetAccepted() { + t.Fatalf("expected accepted payout response") + } + if resp.GetPayout().GetProjectId() != cfg.ProjectID { + t.Fatalf("expected project id %d, got %d", cfg.ProjectID, resp.GetPayout().GetProjectId()) + } + if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING { + t.Fatalf("expected pending status, got %v", resp.GetPayout().GetStatus()) + } + if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated.AsTime()) { + t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime()) + } + + stored, ok := store.Get(req.GetPayoutId()) + if !ok || stored == nil { + t.Fatalf("expected payout state stored") + } + if stored.GetProviderPaymentId() == "" { + t.Fatalf("expected provider payment id") + } +} + +func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) { + cfg := monetix.Config{ + AllowedCurrencies: []string{"RUB"}, + } + processor := newCardPayoutProcessor(zap.NewNop(), cfg, clockpkg.NewSystem(), newCardPayoutStore(), &http.Client{}, nil) + + _, err := processor.Submit(context.Background(), validCardPayoutRequest()) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInternal) { + t.Fatalf("expected internal error, got %v", err) + } +} + +func TestCardPayoutProcessor_ProcessCallback(t *testing.T) { + cfg := monetix.Config{ + SecretKey: "secret", + StatusSuccess: "success", + StatusProcessing: "processing", + AllowedCurrencies: []string{"RUB"}, + } + store := newCardPayoutStore() + processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}, store, &http.Client{}, nil) + + cb := baseCallback() + cb.Payment.Sum.Currency = "RUB" + cb.Signature = "" + sig, err := monetix.SignPayload(cb, cfg.SecretKey) + if err != nil { + t.Fatalf("failed to sign callback: %v", err) + } + cb.Signature = sig + + payload, err := json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + + status, err := processor.ProcessCallback(context.Background(), payload) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if status != http.StatusOK { + t.Fatalf("expected status ok, got %d", status) + } + + state, ok := store.Get(cb.Payment.ID) + if !ok || state == nil { + t.Fatalf("expected payout state stored") + } + if state.GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED { + t.Fatalf("expected processed status, got %v", state.GetStatus()) + } +} diff --git a/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go b/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go new file mode 100644 index 0000000..156097b --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go @@ -0,0 +1,93 @@ +package gateway + +import ( + "testing" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func TestValidateCardTokenPayoutRequest_Valid(t *testing.T) { + cfg := testMonetixConfig() + req := validCardTokenPayoutRequest() + if err := validateCardTokenPayoutRequest(req, cfg); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) { + baseCfg := testMonetixConfig() + cases := []struct { + name string + mutate func(*mntxv1.CardTokenPayoutRequest) + config func(monetix.Config) monetix.Config + expected string + }{ + { + name: "missing_payout_id", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" }, + expected: "missing_payout_id", + }, + { + name: "missing_customer_id", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerId = "" }, + expected: "missing_customer_id", + }, + { + name: "missing_customer_ip", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerIp = "" }, + expected: "missing_customer_ip", + }, + { + name: "invalid_amount", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.AmountMinor = 0 }, + expected: "invalid_amount", + }, + { + name: "missing_currency", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "" }, + expected: "missing_currency", + }, + { + name: "unsupported_currency", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "EUR" }, + config: func(cfg monetix.Config) monetix.Config { + cfg.AllowedCurrencies = []string{"USD"} + return cfg + }, + expected: "unsupported_currency", + }, + { + name: "missing_card_token", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CardToken = "" }, + expected: "missing_card_token", + }, + { + name: "missing_customer_city_when_required", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { + r.CustomerCountry = "US" + r.CustomerCity = "" + r.CustomerAddress = "Main St" + r.CustomerZip = "12345" + }, + config: func(cfg monetix.Config) monetix.Config { + cfg.RequireCustomerAddress = true + return cfg + }, + expected: "missing_customer_city", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := validCardTokenPayoutRequest() + tc.mutate(req) + cfg := baseCfg + if tc.config != nil { + cfg = tc.config(cfg) + } + err := validateCardTokenPayoutRequest(req, cfg) + requireReason(t, err, tc.expected) + }) + } +} diff --git a/api/gateway/mntx/internal/service/gateway/card_tokenize_validation_test.go b/api/gateway/mntx/internal/service/gateway/card_tokenize_validation_test.go new file mode 100644 index 0000000..aeccf26 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_tokenize_validation_test.go @@ -0,0 +1,76 @@ +package gateway + +import ( + "testing" + "time" + + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func TestValidateCardTokenizeRequest_ValidTopLevel(t *testing.T) { + cfg := testMonetixConfig() + req := validCardTokenizeRequest() + if _, err := validateCardTokenizeRequest(req, cfg); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateCardTokenizeRequest_ValidNestedCard(t *testing.T) { + cfg := testMonetixConfig() + req := validCardTokenizeRequest() + req.Card = &mntxv1.CardDetails{ + Pan: "4111111111111111", + ExpMonth: req.CardExpMonth, + ExpYear: req.CardExpYear, + CardHolder: req.CardHolder, + Cvv: req.CardCvv, + } + req.CardPan = "" + req.CardExpMonth = 0 + req.CardExpYear = 0 + req.CardHolder = "" + req.CardCvv = "" + + if _, err := validateCardTokenizeRequest(req, cfg); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateCardTokenizeRequest_Expired(t *testing.T) { + cfg := testMonetixConfig() + req := validCardTokenizeRequest() + now := time.Now().UTC() + req.CardExpMonth = uint32(now.Month()) + req.CardExpYear = uint32(now.Year() - 1) + + _, err := validateCardTokenizeRequest(req, cfg) + requireReason(t, err, "expired_card") +} + +func TestValidateCardTokenizeRequest_MissingCvv(t *testing.T) { + cfg := testMonetixConfig() + req := validCardTokenizeRequest() + req.CardCvv = "" + + _, err := validateCardTokenizeRequest(req, cfg) + requireReason(t, err, "missing_cvv") +} + +func TestValidateCardTokenizeRequest_MissingCardPan(t *testing.T) { + cfg := testMonetixConfig() + req := validCardTokenizeRequest() + req.CardPan = "" + + _, err := validateCardTokenizeRequest(req, cfg) + requireReason(t, err, "missing_card_pan") +} + +func TestValidateCardTokenizeRequest_AddressRequired(t *testing.T) { + cfg := testMonetixConfig() + cfg.RequireCustomerAddress = true + req := validCardTokenizeRequest() + req.CustomerCountry = "" + + _, err := validateCardTokenizeRequest(req, cfg) + requireReason(t, err, "missing_customer_country") +} diff --git a/api/gateway/mntx/internal/service/gateway/payout_get.go b/api/gateway/mntx/internal/service/gateway/payout_get.go index 5ed5af3..bd7c2d0 100644 --- a/api/gateway/mntx/internal/service/gateway/payout_get.go +++ b/api/gateway/mntx/internal/service/gateway/payout_get.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mservice" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" ) func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) { @@ -17,14 +18,19 @@ func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) ( func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] { ref := strings.TrimSpace(req.GetPayoutRef()) + log := s.logger.Named("payout") + log.Info("Get payout request received", zap.String("payout_ref", ref)) if ref == "" { + log.Warn("Get payout request missing payout_ref") return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref")) } payout, ok := s.store.Get(ref) if !ok { + log.Warn("Payout not found", zap.String("payout_ref", ref)) return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref))) } + log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String())) return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout}) } diff --git a/api/gateway/mntx/internal/service/gateway/payout_submit.go b/api/gateway/mntx/internal/service/gateway/payout_submit.go index 02ba67e..72767c6 100644 --- a/api/gateway/mntx/internal/service/gateway/payout_submit.go +++ b/api/gateway/mntx/internal/service/gateway/payout_submit.go @@ -22,8 +22,17 @@ func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequ } func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] { + log := s.logger.Named("payout") + log.Info("Submit payout request received", + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())), + zap.String("currency", strings.TrimSpace(req.GetAmount().GetCurrency())), + zap.String("amount", strings.TrimSpace(req.GetAmount().GetAmount())), + ) + payout, err := s.buildPayout(req) if err != nil { + log.Warn("Submit payout validation failed", zap.Error(err)) return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err) } @@ -31,6 +40,7 @@ func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayout s.emitEvent(payout, nm.NAPending) go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason())) + log.Info("Payout accepted", zap.String("payout_ref", payout.GetPayoutRef()), zap.String("status", payout.GetStatus().String())) return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout}) } @@ -79,6 +89,7 @@ func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout, } func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) { + log := s.logger.Named("payout") outcome := clonePayout(original) if outcome == nil { return @@ -95,6 +106,7 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin observePayoutError(simulatedFailure, outcome.Amount) s.store.Save(outcome) s.emitEvent(outcome, nm.NAUpdated) + log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()), zap.String("failure_reason", simulatedFailure)) return } @@ -102,6 +114,7 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin observePayoutSuccess(outcome.Amount) s.store.Save(outcome) s.emitEvent(outcome, nm.NAUpdated) + log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String())) } func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) { @@ -111,18 +124,18 @@ func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout}) if err != nil { - s.logger.Warn("failed to marshal payout event", zapError(err)) + s.logger.Warn("Failed to marshal payout event", zapError(err)) return } env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action)) if _, err := env.Wrap(payload); err != nil { - s.logger.Warn("failed to wrap payout event payload", zapError(err)) + s.logger.Warn("Failed to wrap payout event payload", zapError(err)) return } if err := s.producer.SendMessage(env); err != nil { - s.logger.Warn("failed to publish payout event", zapError(err)) + s.logger.Warn("Failed to publish payout event", zapError(err)) } } diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index a03f816..ec751a3 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -14,6 +14,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" "google.golang.org/grpc" ) @@ -97,9 +98,19 @@ func (s *Service) Register(router routers.GRPC) error { } func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) { + log := svc.logger.Named("rpc") + log.Info("RPC request started", zap.String("method", method)) + start := svc.clock.Now() resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req) - observeRPC(method, err, svc.clock.Now().Sub(start)) + duration := svc.clock.Now().Sub(start) + observeRPC(method, err, duration) + + if err != nil { + log.Warn("RPC request failed", zap.String("method", method), zap.Duration("duration", duration), zap.Error(err)) + } else { + log.Info("RPC request completed", zap.String("method", method), zap.Duration("duration", duration)) + } return resp, err } diff --git a/api/gateway/mntx/internal/service/gateway/testhelpers_test.go b/api/gateway/mntx/internal/service/gateway/testhelpers_test.go new file mode 100644 index 0000000..0284a2d --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/testhelpers_test.go @@ -0,0 +1,84 @@ +package gateway + +import ( + "errors" + "testing" + "time" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func requireReason(t *testing.T, err error, reason string) { + t.Helper() + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + reasoned, ok := err.(payoutFailure) + if !ok { + t.Fatalf("expected payout failure reason, got %T", err) + } + if reasoned.Reason() != reason { + t.Fatalf("expected reason %q, got %q", reason, reasoned.Reason()) + } +} + +func testMonetixConfig() monetix.Config { + return monetix.Config{ + AllowedCurrencies: []string{"RUB", "USD"}, + } +} + +func validCardPayoutRequest() *mntxv1.CardPayoutRequest { + return &mntxv1.CardPayoutRequest{ + PayoutId: "payout-1", + CustomerId: "cust-1", + CustomerFirstName: "Jane", + CustomerLastName: "Doe", + CustomerIp: "203.0.113.10", + AmountMinor: 1500, + Currency: "RUB", + CardPan: "4111111111111111", + CardHolder: "JANE DOE", + CardExpMonth: 12, + CardExpYear: 2035, + } +} + +func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest { + return &mntxv1.CardTokenPayoutRequest{ + PayoutId: "payout-1", + CustomerId: "cust-1", + CustomerFirstName: "Jane", + CustomerLastName: "Doe", + CustomerIp: "203.0.113.11", + AmountMinor: 2500, + Currency: "USD", + CardToken: "tok_123", + } +} + +func validCardTokenizeRequest() *mntxv1.CardTokenizeRequest { + month, year := futureExpiry() + return &mntxv1.CardTokenizeRequest{ + RequestId: "req-1", + CustomerId: "cust-1", + CustomerFirstName: "Jane", + CustomerLastName: "Doe", + CustomerIp: "203.0.113.12", + CardPan: "4111111111111111", + CardHolder: "JANE DOE", + CardCvv: "123", + CardExpMonth: month, + CardExpYear: year, + } +} + +func futureExpiry() (uint32, uint32) { + now := time.Now().UTC() + return uint32(now.Month()), uint32(now.Year() + 1) +} diff --git a/api/gateway/mntx/internal/service/monetix/client.go b/api/gateway/mntx/internal/service/monetix/client.go index cfe5f3b..9ae2db1 100644 --- a/api/gateway/mntx/internal/service/monetix/client.go +++ b/api/gateway/mntx/internal/service/monetix/client.go @@ -2,10 +2,6 @@ package monetix import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" "net/http" "github.com/tech/sendico/pkg/mlogger" @@ -45,21 +41,3 @@ func (c *Client) CreateCardTokenPayout(ctx context.Context, req CardTokenPayoutR func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) { return c.sendTokenization(ctx, req) } - -func signPayload(payload any, secret string) (string, error) { - data, err := json.Marshal(payload) - if err != nil { - return "", err - } - - h := hmac.New(sha256.New, []byte(secret)) - if _, err := h.Write(data); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -// SignPayload exposes signature calculation for callback verification. -func SignPayload(payload any, secret string) (string, error) { - return signPayload(payload, secret) -} diff --git a/api/gateway/mntx/internal/service/monetix/mask_test.go b/api/gateway/mntx/internal/service/monetix/mask_test.go new file mode 100644 index 0000000..6cf6c77 --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/mask_test.go @@ -0,0 +1,23 @@ +package monetix + +import "testing" + +func TestMaskPAN(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {input: "1234", expected: "****"}, + {input: "1234567890", expected: "12******90"}, + {input: "1234567890123456", expected: "123456******3456"}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := MaskPAN(tc.input) + if got != tc.expected { + t.Fatalf("expected %q, got %q", tc.expected, got) + } + }) + } +} diff --git a/api/gateway/mntx/internal/service/monetix/sender.go b/api/gateway/mntx/internal/service/monetix/sender.go index e9ad47c..90cc406 100644 --- a/api/gateway/mntx/internal/service/monetix/sender.go +++ b/api/gateway/mntx/internal/service/monetix/sender.go @@ -24,7 +24,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca maskedPAN := MaskPAN(req.Card.PAN) return c.send(ctx, &req, "/v2/payment/card/payout", func() { - c.logger.Info("dispatching Monetix card payout", + 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), @@ -47,7 +47,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca 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", + 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), @@ -101,7 +101,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json") - c.logger.Info("dispatching Monetix card tokenization", + c.logger.Info("Dispatching Monetix card tokenization", zap.String("request_id", req.General.PaymentID), zap.String("masked_pan", MaskPAN(req.Card.PAN)), ) @@ -111,7 +111,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) duration := time.Since(start) if err != nil { observeRequest(outcomeNetworkError, duration) - c.logger.Warn("monetix tokenization request failed", zap.Error(err)) + c.logger.Warn("Monetix tokenization request failed", zap.Error(err)) return nil, merrors.Internal("monetix tokenization request failed: " + err.Error()) } defer resp.Body.Close() @@ -133,7 +133,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) 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)) + 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"` @@ -245,7 +245,7 @@ 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 { - c.logger.Warn("failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err)) + c.logger.Warn("Failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err)) } } diff --git a/api/gateway/mntx/internal/service/monetix/sender_test.go b/api/gateway/mntx/internal/service/monetix/sender_test.go new file mode 100644 index 0000000..6e26aea --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/sender_test.go @@ -0,0 +1,128 @@ +package monetix + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "go.uber.org/zap" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func TestSendCardPayout_SignsPayload(t *testing.T) { + secret := "secret" + var captured CardPayoutRequest + + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/v2/payment/card/payout" { + t.Fatalf("expected payout 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) + } + resp := APIResponse{} + resp.Operation.RequestID = "req-1" + payload, _ := json.Marshal(resp) + 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: 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 captured.General.Signature == "" { + t.Fatalf("expected signature in request") + } + + signed := captured + signed.General.Signature = "" + expectedSig, err := SignPayload(signed, secret) + if err != nil { + t.Fatalf("failed to compute signature: %v", err) + } + if captured.General.Signature != expectedSig { + t.Fatalf("expected signature %q, got %q", expectedSig, captured.General.Signature) + } +} + +func TestSendCardPayout_HTTPError(t *testing.T) { + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + body := `{"code":"E100","message":"denied"}` + return &http.Response{ + StatusCode: http.StatusBadRequest, + 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.Accepted { + t.Fatalf("expected rejected response") + } + if result.ErrorCode != "E100" { + t.Fatalf("expected error code E100, got %q", result.ErrorCode) + } + if result.ErrorMessage != "denied" { + t.Fatalf("expected error message denied, got %q", result.ErrorMessage) + } +} diff --git a/api/gateway/mntx/internal/service/monetix/signature.go b/api/gateway/mntx/internal/service/monetix/signature.go new file mode 100644 index 0000000..a6e3258 --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/signature.go @@ -0,0 +1,112 @@ +package monetix + +import ( + "bytes" + "crypto/hmac" + "crypto/sha512" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" +) + +func signPayload(payload any, secret string) (string, error) { + canonical, err := signaturePayloadString(payload) + if err != nil { + return "", err + } + + mac := hmac.New(sha512.New, []byte(secret)) + if _, err := mac.Write([]byte(canonical)); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil +} + +// SignPayload exposes signature calculation for callback verification. +func SignPayload(payload any, secret string) (string, error) { + return signPayload(payload, secret) +} + +func signaturePayloadString(payload any) (string, error) { + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + + var root any + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&root); err != nil { + return "", err + } + + lines := make([]string, 0) + collectSignatureLines(nil, root, &lines) + sort.Strings(lines) + + return strings.Join(lines, ";"), nil +} + +func collectSignatureLines(path []string, value any, lines *[]string) { + switch v := value.(type) { + case map[string]any: + for key, child := range v { + if strings.EqualFold(key, "signature") { + continue + } + collectSignatureLines(append(path, key), child, lines) + } + case []any: + if len(v) == 0 { + return + } + for idx, child := range v { + collectSignatureLines(append(path, strconv.Itoa(idx)), child, lines) + } + default: + line := formatSignatureLine(path, v) + if line != "" { + *lines = append(*lines, line) + } + } +} + +func formatSignatureLine(path []string, value any) string { + if len(path) == 0 { + return "" + } + val := signatureValueString(value) + segments := append(append([]string{}, path...), val) + return strings.Join(segments, ":") +} + +func signatureValueString(value any) string { + switch v := value.(type) { + case nil: + return "null" + case string: + return v + case json.Number: + return v.String() + case bool: + if v { + return "1" + } + return "0" + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32) + case int: + return strconv.Itoa(v) + case int8, int16, int32, int64: + return fmt.Sprint(v) + case uint, uint8, uint16, uint32, uint64: + return fmt.Sprint(v) + default: + return fmt.Sprint(v) + } +} diff --git a/api/gateway/mntx/internal/service/monetix/signature_test.go b/api/gateway/mntx/internal/service/monetix/signature_test.go new file mode 100644 index 0000000..aab44f6 --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/signature_test.go @@ -0,0 +1,148 @@ +package monetix + +import "testing" + +func TestSignaturePayloadString_Example(t *testing.T) { + payload := map[string]any{ + "general": map[string]any{ + "project_id": 3254, + "payment_id": "id_38202316", + "signature": "", + }, + "customer": map[string]any{ + "id": "585741", + "email": "johndoe@example.com", + "first_name": "John", + "last_name": "Doe", + "address": "Downing str., 23", + "identify": map[string]any{ + "doc_number": "54122312544", + }, + "ip_address": "198.51.100.47", + }, + "payment": map[string]any{ + "amount": 10800, + "currency": "USD", + "description": "Computer keyboards", + }, + "receipt_data": map[string]any{ + "positions": []any{ + map[string]any{ + "quantity": "10", + "amount": "108", + "description": "Computer keyboard", + }, + }, + }, + "return_url": map[string]any{ + "success": "https://paymentpage.example.com/complete-redirect?id=success", + "decline": "https://paymentpage.example.com/complete-redirect?id=decline", + }, + } + + got, err := signaturePayloadString(payload) + if err != nil { + t.Fatalf("failed to build signature string: %v", err) + } + + expected := "customer:address:Downing str., 23;customer:email:johndoe@example.com;customer:first_name:John;customer:id:585741;customer:identify:doc_number:54122312544;customer:ip_address:198.51.100.47;customer:last_name:Doe;general:payment_id:id_38202316;general:project_id:3254;payment:amount:10800;payment:currency:USD;payment:description:Computer keyboards;receipt_data:positions:0:amount:108;receipt_data:positions:0:description:Computer keyboard;receipt_data:positions:0:quantity:10;return_url:decline:https://paymentpage.example.com/complete-redirect?id=decline;return_url:success:https://paymentpage.example.com/complete-redirect?id=success" + if got != expected { + t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got) + } +} + +func TestSignPayload_Example(t *testing.T) { + payload := map[string]any{ + "general": map[string]any{ + "project_id": 3254, + "payment_id": "id_38202316", + "signature": "", + }, + "customer": map[string]any{ + "id": "585741", + "email": "johndoe@example.com", + "first_name": "John", + "last_name": "Doe", + "address": "Downing str., 23", + "identify": map[string]any{ + "doc_number": "54122312544", + }, + "ip_address": "198.51.100.47", + }, + "payment": map[string]any{ + "amount": 10800, + "currency": "USD", + "description": "Computer keyboards", + }, + "receipt_data": map[string]any{ + "positions": []any{ + map[string]any{ + "quantity": "10", + "amount": "108", + "description": "Computer keyboard", + }, + }, + }, + "return_url": map[string]any{ + "success": "https://paymentpage.example.com/complete-redirect?id=success", + "decline": "https://paymentpage.example.com/complete-redirect?id=decline", + }, + } + + got, err := SignPayload(payload, "secret") + if err != nil { + t.Fatalf("failed to sign payload: %v", err) + } + expected := "lagSnuspAn+F6XkmQISqwtBg0PsiTy62fF9x33TM+278mnufIDZyi1yP0BQALuCxyikkIxIMbodBn2F8hMdRwA==" + if got != expected { + t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got) + } +} + +func TestSignaturePayloadString_BooleansAndArrays(t *testing.T) { + payload := map[string]any{ + "flag": true, + "false_flag": false, + "empty": "", + "zero": 0, + "nested": map[string]any{ + "list": []any{}, + "items": []any{"alpha", "beta"}, + }, + } + + got, err := signaturePayloadString(payload) + if err != nil { + t.Fatalf("failed to build signature string: %v", err) + } + + expected := "empty:;false_flag:0;flag:1;nested:items:0:alpha;nested:items:1:beta;zero:0" + if got != expected { + t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got) + } +} + +func TestSignPayload_EthEstimateGasExample(t *testing.T) { + payload := map[string]any{ + "jsonrpc": "2.0", + "id": 3, + "method": "eth_estimateGas", + "params": []any{ + map[string]any{ + "from": "0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8", + "to": "0x44162e39eefd9296231e772663a92e72958e182f", + "gasPrice": "0x64", + "data": "0xa9059cbb00000000000000000000000044162e39eefd9296231e772663a92e72958e182f00000000000000000000000000000000000000000000000000000000000f4240", + }, + }, + } + + got, err := SignPayload(payload, "1") + if err != nil { + t.Fatalf("failed to sign payload: %v", err) + } + expected := "C4WbSvXKSMyX8yLamQcUe/Nzr6nSt9m3HYn4jHSyA7yi/FaTiqk0r8BlfIzfxSCoDaRgrSd82ihgZW+DxELhdQ==" + if got != expected { + t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got) + } +}