improved logging + autotests

This commit is contained in:
Stephan D
2025-12-26 12:26:28 +01:00
parent 5191336a49
commit 171d90b3f7
20 changed files with 1282 additions and 56 deletions

View File

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

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

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

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

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] {
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))
}
}

View File

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

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