Merge pull request 'improved logging + autotests' (#189) from mntx-188 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

Reviewed-on: #189
This commit was merged in pull request #189.
This commit is contained in:
2025-12-26 11:26:52 +00:00
20 changed files with 1282 additions and 56 deletions

View File

@@ -95,22 +95,49 @@ func (i *Imp) Shutdown() {
} }
func (i *Imp) Start() error { 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() cfg, err := i.loadConfig()
if err != nil { if err != nil {
return err return err
} }
i.config = cfg 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) monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
if err != nil { if err != nil {
i.logger.Error("Failed to resolve Monetix configuration", zap.Error(err))
return err return err
} }
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback) callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
if err != nil { if err != nil {
i.logger.Error("Failed to resolve callback configuration", zap.Error(err))
return 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) { serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
svc := mntxservice.NewService(logger, svc := mntxservice.NewService(logger,
mntxservice.WithProducer(producer), mntxservice.WithProducer(producer),
@@ -137,7 +164,7 @@ func (i *Imp) Start() error {
func (i *Imp) loadConfig() (*config, error) { func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file) data, err := os.ReadFile(i.file)
if err != nil { 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 return nil, err
} }
@@ -145,7 +172,7 @@ func (i *Imp) loadConfig() (*config, error) {
Config: &grpcapp.Config{}, Config: &grpcapp.Config{},
} }
if err := yaml.Unmarshal(data, cfg); err != nil { 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 return nil, err
} }
@@ -245,7 +272,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig,
} }
_, block, err := net.ParseCIDR(clean) _, block, err := net.ParseCIDR(clean)
if err != nil { 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 continue
} }
cidrs = append(cidrs, block) cidrs = append(cidrs, block)
@@ -270,20 +297,36 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
router := chi.NewRouter() router := chi.NewRouter()
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) { 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) { 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) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes)) body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
if err != nil { if err != nil {
log.Warn("Callback body read failed", zap.Error(err))
http.Error(w, "failed to read body", http.StatusBadRequest) http.Error(w, "failed to read body", http.StatusBadRequest)
return return
} }
status, err := svc.ProcessMonetixCallback(r.Context(), body) status, err := svc.ProcessMonetixCallback(r.Context(), body)
if err != nil { if err != nil {
log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status))
http.Error(w, err.Error(), status) http.Error(w, err.Error(), status)
return return
} }
log.Debug("Callback processed", zap.Int("status", status))
w.WriteHeader(status) w.WriteHeader(status)
}) })
@@ -301,7 +344,7 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
go func() { go func() {
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { 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))
} }
}() }()

View File

@@ -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")
}
}

View File

@@ -10,6 +10,7 @@ import (
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -66,9 +67,12 @@ type monetixCallback struct {
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state. // ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) { func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
log := s.logger.Named("callback")
if s.card == nil { if s.card == nil {
log.Warn("Card payout processor not initialised")
return http.StatusInternalServerError, merrors.Internal("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) return s.card.ProcessCallback(ctx, payload)
} }

View File

@@ -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")
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/proto" "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] { 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 { 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")) return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
} }
resp, err := s.card.Submit(ctx, req) resp, err := s.card.Submit(ctx, req)
if err != nil { if err != nil {
log.Warn("Card payout submission failed", zap.Error(err))
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, 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) 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] { 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 { 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")) return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
} }
resp, err := s.card.SubmitToken(ctx, req) resp, err := s.card.SubmitToken(ctx, req)
if err != nil { if err != nil {
log.Warn("Card token payout submission failed", zap.Error(err))
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, 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) 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] { 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 { 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")) return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
} }
resp, err := s.card.Tokenize(ctx, req) resp, err := s.card.Tokenize(ctx, req)
if err != nil { if err != nil {
log.Warn("Card tokenization failed", zap.Error(err))
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, 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) 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] { 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 { 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")) 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()) state, err := s.card.Status(context.Background(), req.GetPayoutId())
if err != nil { if err != nil {
log.Warn("Card payout status lookup failed", zap.Error(err))
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, 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}) return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
} }

View File

@@ -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)
})
}
}

View File

@@ -45,14 +45,20 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
if p == nil { if p == nil {
return nil, merrors.Internal("card payout processor not initialised") 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) == "" { 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") return nil, merrors.Internal("monetix configuration is incomplete")
} }
req = sanitizeCardPayoutRequest(req) req = sanitizeCardPayoutRequest(req)
if err := validateCardPayoutRequest(req, p.config); err != nil { 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("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()), zap.String("customer_id", req.GetCustomerId()),
zap.Error(err), zap.Error(err),
@@ -65,7 +71,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
projectID = p.config.ProjectID projectID = p.config.ProjectID
} }
if projectID == 0 { 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") 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.ProviderMessage = err.Error()
state.UpdatedAt = timestamppb.New(p.clock.Now()) state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state) 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("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()), zap.String("customer_id", req.GetCustomerId()),
zap.Error(err), zap.Error(err),
@@ -122,6 +128,13 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
ErrorMessage: result.ErrorMessage, 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 return resp, nil
} }
@@ -129,14 +142,20 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
if p == nil { if p == nil {
return nil, merrors.Internal("card payout processor not initialised") 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) == "" { 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") return nil, merrors.Internal("monetix configuration is incomplete")
} }
req = sanitizeCardTokenPayoutRequest(req) req = sanitizeCardTokenPayoutRequest(req)
if err := validateCardTokenPayoutRequest(req, p.config); err != nil { 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("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()), zap.String("customer_id", req.GetCustomerId()),
zap.Error(err), zap.Error(err),
@@ -149,7 +168,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
projectID = p.config.ProjectID projectID = p.config.ProjectID
} }
if projectID == 0 { 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") 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.ProviderMessage = err.Error()
state.UpdatedAt = timestamppb.New(p.clock.Now()) state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state) 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("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()), zap.String("customer_id", req.GetCustomerId()),
zap.Error(err), zap.Error(err),
@@ -206,6 +225,13 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
ErrorMessage: result.ErrorMessage, 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 return resp, nil
} }
@@ -213,9 +239,13 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
if p == nil { if p == nil {
return nil, merrors.Internal("card payout processor not initialised") 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) cardInput, err := validateCardTokenizeRequest(req, p.config)
if err != nil { 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("request_id", req.GetRequestId()),
zap.String("customer_id", req.GetCustomerId()), zap.String("customer_id", req.GetCustomerId()),
zap.Error(err), zap.Error(err),
@@ -228,7 +258,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
projectID = p.config.ProjectID projectID = p.config.ProjectID
} }
if projectID == 0 { 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") 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) apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
result, err := client.CreateCardTokenization(ctx, apiReq) result, err := client.CreateCardTokenization(ctx, apiReq)
if err != nil { 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("request_id", req.GetRequestId()),
zap.String("customer_id", req.GetCustomerId()), zap.String("customer_id", req.GetCustomerId()),
zap.Error(err), zap.Error(err),
@@ -258,6 +288,12 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
resp.ExpiryYear = result.ExpiryYear resp.ExpiryYear = result.ExpiryYear
resp.CardBrand = result.CardBrand 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 return resp, nil
} }
@@ -267,16 +303,18 @@ func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv
} }
id := strings.TrimSpace(payoutID) id := strings.TrimSpace(payoutID)
p.logger.Info("Card payout status requested", zap.String("payout_id", id))
if 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") return nil, merrors.InvalidArgument("payout_id is required", "payout_id")
} }
state, ok := p.store.Get(id) state, ok := p.store.Get(id)
if !ok || state == nil { 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") 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 return state, nil
} }
@@ -284,18 +322,19 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
if p == nil { if p == nil {
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") 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 { 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") return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty")
} }
if strings.TrimSpace(p.config.SecretKey) == "" { 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") return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
} }
var cb monetixCallback var cb monetixCallback
if err := json.Unmarshal(payload, &cb); err != nil { 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 return http.StatusBadRequest, err
} }
@@ -318,7 +357,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
p.emitCardPayoutEvent(state) p.emitCardPayoutEvent(state)
monetix.ObserveCallback(statusLabel) 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("payout_id", state.GetPayoutId()),
zap.String("status", statusLabel), zap.String("status", statusLabel),
zap.String("provider_code", state.GetProviderCode()), zap.String("provider_code", state.GetProviderCode()),
@@ -337,16 +376,16 @@ func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState)
event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state} event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state}
payload, err := protojson.Marshal(event) payload, err := protojson.Marshal(event)
if err != nil { 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 return
} }
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated)) env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated))
if _, err := env.Wrap(payload); err != nil { 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 return
} }
if err := p.producer.SendMessage(env); err != nil { 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))
} }
} }

View File

@@ -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())
}
}

View File

@@ -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)
})
}
}

View File

@@ -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")
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" 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) { 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] { func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
ref := strings.TrimSpace(req.GetPayoutRef()) ref := strings.TrimSpace(req.GetPayoutRef())
log := s.logger.Named("payout")
log.Info("Get payout request received", zap.String("payout_ref", ref))
if 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")) return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
} }
payout, ok := s.store.Get(ref) payout, ok := s.store.Get(ref)
if !ok { 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))) 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}) return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
} }

View File

@@ -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] { 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) payout, err := s.buildPayout(req)
if err != nil { if err != nil {
log.Warn("Submit payout validation failed", zap.Error(err))
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, 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) s.emitEvent(payout, nm.NAPending)
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason())) 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}) 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) { func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
log := s.logger.Named("payout")
outcome := clonePayout(original) outcome := clonePayout(original)
if outcome == nil { if outcome == nil {
return return
@@ -95,6 +106,7 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin
observePayoutError(simulatedFailure, outcome.Amount) observePayoutError(simulatedFailure, outcome.Amount)
s.store.Save(outcome) s.store.Save(outcome)
s.emitEvent(outcome, nm.NAUpdated) 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 return
} }
@@ -102,6 +114,7 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin
observePayoutSuccess(outcome.Amount) observePayoutSuccess(outcome.Amount)
s.store.Save(outcome) s.store.Save(outcome)
s.emitEvent(outcome, nm.NAUpdated) 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) { 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}) payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
if err != nil { if err != nil {
s.logger.Warn("failed to marshal payout event", zapError(err)) s.logger.Warn("Failed to marshal payout event", zapError(err))
return return
} }
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action)) env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
if _, err := env.Wrap(payload); err != nil { 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 return
} }
if err := s.producer.SendMessage(env); err != nil { 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))
} }
} }

View File

@@ -14,6 +14,7 @@ import (
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/grpc" "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) { 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() start := svc.clock.Now()
resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req) 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 return resp, err
} }

View File

@@ -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)
}

View File

@@ -2,10 +2,6 @@ package monetix
import ( import (
"context" "context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http" "net/http"
"github.com/tech/sendico/pkg/mlogger" "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) { func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) {
return c.sendTokenization(ctx, req) 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)
}

View File

@@ -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)
}
})
}
}

View File

@@ -24,7 +24,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca
maskedPAN := MaskPAN(req.Card.PAN) maskedPAN := MaskPAN(req.Card.PAN)
return c.send(ctx, &req, "/v2/payment/card/payout", return c.send(ctx, &req, "/v2/payment/card/payout",
func() { func() {
c.logger.Info("dispatching Monetix card payout", c.logger.Info("Dispatching Monetix card payout",
zap.String("payout_id", req.General.PaymentID), zap.String("payout_id", req.General.PaymentID),
zap.Int64("amount_minor", req.Payment.Amount), zap.Int64("amount_minor", req.Payment.Amount),
zap.String("currency", req.Payment.Currency), 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) { func (c *Client) sendCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) {
return c.send(ctx, &req, "/v2/payment/card/payout/token", return c.send(ctx, &req, "/v2/payment/card/payout/token",
func() { 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.String("payout_id", req.General.PaymentID),
zap.Int64("amount_minor", req.Payment.Amount), zap.Int64("amount_minor", req.Payment.Amount),
zap.String("currency", req.Payment.Currency), 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("Content-Type", "application/json")
httpReq.Header.Set("Accept", "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("request_id", req.General.PaymentID),
zap.String("masked_pan", MaskPAN(req.Card.PAN)), 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) duration := time.Since(start)
if err != nil { if err != nil {
observeRequest(outcomeNetworkError, duration) 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()) return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -133,7 +133,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
var apiResp APIResponse var apiResp APIResponse
if len(body) > 0 { if len(body) > 0 {
if err := json.Unmarshal(body, &apiResp); err != nil { 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 { } else {
var tokenData struct { var tokenData struct {
Token string `json:"token"` Token string `json:"token"`
@@ -245,7 +245,7 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
var apiResp APIResponse var apiResp APIResponse
if len(body) > 0 { if len(body) > 0 {
if err := json.Unmarshal(body, &apiResp); err != nil { 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))
} }
} }

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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": "<ignored>",
},
"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": "<ignored>",
},
"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)
}
}