monetix gateway

This commit is contained in:
Stephan D
2025-12-04 21:16:15 +01:00
parent f439f53524
commit 396a0c0c88
47 changed files with 3835 additions and 3 deletions

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
info := version.Info{
Program: "Sendico Monetix Gateway Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -0,0 +1,345 @@
package serverimp
import (
"context"
"errors"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
app *grpcapp.App[struct{}]
http *http.Server
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Monetix monetixConfig `yaml:"monetix"`
HTTP httpConfig `yaml:"http"`
}
type monetixConfig struct {
BaseURL string `yaml:"base_url"`
BaseURLEnv string `yaml:"base_url_env"`
ProjectID int64 `yaml:"project_id"`
ProjectIDEnv string `yaml:"project_id_env"`
SecretKey string `yaml:"secret_key"`
SecretKeyEnv string `yaml:"secret_key_env"`
AllowedCurrencies []string `yaml:"allowed_currencies"`
RequireCustomerAddress bool `yaml:"require_customer_address"`
RequestTimeoutSeconds int `yaml:"request_timeout_seconds"`
StatusSuccess string `yaml:"status_success"`
StatusProcessing string `yaml:"status_processing"`
}
type httpConfig struct {
Callback callbackConfig `yaml:"callback"`
}
type callbackConfig struct {
Address string `yaml:"address"`
Path string `yaml:"path"`
AllowedCIDRs []string `yaml:"allowed_cidrs"`
MaxBodyBytes int64 `yaml:"max_body_bytes"`
}
// Create initialises the Monetix gateway server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if i.http != nil {
_ = i.http.Shutdown(ctx)
i.http = nil
}
i.app.Shutdown(ctx)
}
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
if err != nil {
return err
}
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
if err != nil {
return err
}
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
svc := mntxservice.NewService(logger,
mntxservice.WithProducer(producer),
mntxservice.WithMonetixConfig(monetixCfg),
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
)
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
return nil, err
}
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "mntx_gateway", cfg.Config, i.debug, nil, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{
Config: &grpcapp.Config{},
}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50075",
EnableReflection: true,
EnableHealth: true,
}
}
if cfg.Metrics == nil {
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9404"}
}
return cfg, nil
}
func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (mntxservice.MonetixConfig, error) {
baseURL := strings.TrimSpace(cfg.BaseURL)
if env := strings.TrimSpace(cfg.BaseURLEnv); env != "" {
if val := strings.TrimSpace(os.Getenv(env)); val != "" {
baseURL = val
}
}
projectID := cfg.ProjectID
if projectID == 0 && strings.TrimSpace(cfg.ProjectIDEnv) != "" {
raw := strings.TrimSpace(os.Getenv(cfg.ProjectIDEnv))
if raw != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
projectID = id
} else {
return mntxservice.MonetixConfig{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "monetix.project_id")
}
}
}
secret := strings.TrimSpace(cfg.SecretKey)
if env := strings.TrimSpace(cfg.SecretKeyEnv); env != "" {
if val := strings.TrimSpace(os.Getenv(env)); val != "" {
secret = val
}
}
timeout := time.Duration(cfg.RequestTimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 15 * time.Second
}
statusSuccess := strings.TrimSpace(cfg.StatusSuccess)
statusProcessing := strings.TrimSpace(cfg.StatusProcessing)
return mntxservice.MonetixConfig{
BaseURL: baseURL,
ProjectID: projectID,
SecretKey: secret,
AllowedCurrencies: cfg.AllowedCurrencies,
RequireCustomerAddress: cfg.RequireCustomerAddress,
RequestTimeout: timeout,
StatusSuccess: statusSuccess,
StatusProcessing: statusProcessing,
}, nil
}
type callbackRuntimeConfig struct {
Address string
Path string
AllowedCIDRs []*net.IPNet
MaxBodyBytes int64
}
func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig, error) {
addr := strings.TrimSpace(cfg.Address)
if addr == "" {
addr = ":8080"
}
path := strings.TrimSpace(cfg.Path)
if path == "" {
path = "/monetix/callback"
}
maxBody := cfg.MaxBodyBytes
if maxBody <= 0 {
maxBody = 1 << 20 // 1MB
}
var cidrs []*net.IPNet
for _, raw := range cfg.AllowedCIDRs {
clean := strings.TrimSpace(raw)
if clean == "" {
continue
}
_, block, err := net.ParseCIDR(clean)
if err != nil {
i.logger.Warn("invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
continue
}
cidrs = append(cidrs, block)
}
return callbackRuntimeConfig{
Address: addr,
Path: path,
AllowedCIDRs: cidrs,
MaxBodyBytes: maxBody,
}, nil
}
func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRuntimeConfig) error {
if svc == nil {
return errors.New("nil service provided for callback server")
}
if strings.TrimSpace(cfg.Address) == "" {
i.logger.Info("Monetix callback server disabled: address is empty")
return nil
}
router := chi.NewRouter()
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
status, err := svc.ProcessMonetixCallback(r.Context(), body)
if err != nil {
http.Error(w, err.Error(), status)
return
}
w.WriteHeader(status)
})
server := &http.Server{
Addr: cfg.Address,
Handler: router,
}
ln, err := net.Listen("tcp", cfg.Address)
if err != nil {
return err
}
i.http = server
go func() {
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
i.logger.Error("Monetix callback server stopped with error", zap.Error(err))
}
}()
i.logger.Info("Monetix callback server listening", zap.String("address", cfg.Address), zap.String("path", cfg.Path))
return nil
}
func clientAllowed(r *http.Request, cidrs []*net.IPNet) bool {
if len(cidrs) == 0 {
return true
}
host := clientIPFromRequest(r)
if host == nil {
return false
}
for _, block := range cidrs {
if block.Contains(host) {
return true
}
}
return false
}
func clientIPFromRequest(r *http.Request) net.IP {
if r == nil {
return nil
}
if xfwd := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xfwd != "" {
parts := strings.Split(xfwd, ",")
if len(parts) > 0 {
if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil {
return ip
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return nil
}
return net.ParseIP(host)
}

View File

@@ -0,0 +1,12 @@
package server
import (
serverimp "github.com/tech/sendico/gateway/mntx/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
// Create constructs the Monetix gateway server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,134 @@
package gateway
import (
"context"
"crypto/hmac"
"net/http"
"strings"
"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"
"google.golang.org/protobuf/types/known/timestamppb"
)
type callbackPayment struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Date string `json:"date"`
Method string `json:"method"`
Description string `json:"description"`
Sum struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
} `json:"sum"`
}
type callbackOperation struct {
ID int64 `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Date string `json:"date"`
CreatedDate string `json:"created_date"`
RequestID string `json:"request_id"`
SumInitial struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
} `json:"sum_initial"`
SumConverted struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
} `json:"sum_converted"`
Provider struct {
ID int64 `json:"id"`
PaymentID string `json:"payment_id"`
Date string `json:"date"`
AuthCode string `json:"auth_code"`
} `json:"provider"`
Code string `json:"code"`
Message string `json:"message"`
}
type monetixCallback struct {
ProjectID int64 `json:"project_id"`
Payment callbackPayment `json:"payment"`
Account struct {
Number string `json:"number"`
} `json:"account"`
Customer struct {
ID string `json:"id"`
} `json:"customer"`
Operation callbackOperation `json:"operation"`
Signature string `json:"signature"`
}
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
if s.card == nil {
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
}
return s.card.ProcessCallback(ctx, payload)
}
func mapCallbackToState(clock clockpkg.Clock, cfg monetix.Config, cb monetixCallback) (*mntxv1.CardPayoutState, string) {
status := strings.ToLower(strings.TrimSpace(cb.Payment.Status))
opStatus := strings.ToLower(strings.TrimSpace(cb.Operation.Status))
code := strings.TrimSpace(cb.Operation.Code)
outcome := monetix.OutcomeDecline
internalStatus := mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
if status == cfg.SuccessStatus() && opStatus == cfg.SuccessStatus() && (code == "" || code == "0") {
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
outcome = monetix.OutcomeSuccess
} else if status == cfg.ProcessingStatus() || opStatus == cfg.ProcessingStatus() {
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
outcome = monetix.OutcomeProcessing
}
now := timestamppb.New(clock.Now())
state := &mntxv1.CardPayoutState{
PayoutId: cb.Payment.ID,
ProjectId: cb.ProjectID,
CustomerId: cb.Customer.ID,
AmountMinor: cb.Payment.Sum.Amount,
Currency: strings.ToUpper(strings.TrimSpace(cb.Payment.Sum.Currency)),
Status: internalStatus,
ProviderCode: cb.Operation.Code,
ProviderMessage: cb.Operation.Message,
ProviderPaymentId: fallbackProviderPaymentID(cb),
UpdatedAt: now,
CreatedAt: now,
}
return state, outcome
}
func fallbackProviderPaymentID(cb monetixCallback) string {
if cb.Operation.Provider.PaymentID != "" {
return cb.Operation.Provider.PaymentID
}
if cb.Operation.RequestID != "" {
return cb.Operation.RequestID
}
return cb.Payment.ID
}
func verifyCallbackSignature(cb monetixCallback, secret string) error {
expected := cb.Signature
cb.Signature = ""
calculated, err := monetix.SignPayload(cb, secret)
if err != nil {
return err
}
if subtleConstantTimeCompare(expected, calculated) {
return nil
}
return merrors.DataConflict("signature mismatch")
}
func subtleConstantTimeCompare(a, b string) bool {
return hmac.Equal([]byte(strings.TrimSpace(a)), []byte(strings.TrimSpace(b)))
}

View File

@@ -0,0 +1,255 @@
package gateway
import (
"context"
"strings"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/proto"
)
func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
return executeUnary(ctx, s, "CreateCardPayout", s.handleCreateCardPayout, req)
}
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
if s.card == nil {
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 {
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
}
return gsresponse.Success(resp)
}
func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
return executeUnary(ctx, s, "CreateCardTokenPayout", s.handleCreateCardTokenPayout, req)
}
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
if s.card == nil {
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 {
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
}
return gsresponse.Success(resp)
}
func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) {
return executeUnary(ctx, s, "CreateCardToken", s.handleCreateCardToken, req)
}
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
if s.card == nil {
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 {
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
}
return gsresponse.Success(resp)
}
func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
return executeUnary(ctx, s, "GetCardPayoutStatus", s.handleGetCardPayoutStatus, req)
}
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
if s.card == nil {
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 {
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
}
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
}
func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayoutRequest {
if req == nil {
return nil
}
clean := proto.Clone(req)
r, ok := clean.(*mntxv1.CardPayoutRequest)
if !ok {
return req
}
r.PayoutId = strings.TrimSpace(r.GetPayoutId())
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency()))
r.CardPan = strings.TrimSpace(r.GetCardPan())
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
return r
}
func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1.CardTokenPayoutRequest {
if req == nil {
return nil
}
clean := proto.Clone(req)
r, ok := clean.(*mntxv1.CardTokenPayoutRequest)
if !ok {
return req
}
r.PayoutId = strings.TrimSpace(r.GetPayoutId())
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency()))
r.CardToken = strings.TrimSpace(r.GetCardToken())
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
r.MaskedPan = strings.TrimSpace(r.GetMaskedPan())
return r
}
func sanitizeCardTokenizeRequest(req *mntxv1.CardTokenizeRequest) *mntxv1.CardTokenizeRequest {
if req == nil {
return nil
}
clean := proto.Clone(req)
r, ok := clean.(*mntxv1.CardTokenizeRequest)
if !ok {
return req
}
r.RequestId = strings.TrimSpace(r.GetRequestId())
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
r.CardPan = strings.TrimSpace(r.GetCardPan())
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
r.CardCvv = strings.TrimSpace(r.GetCardCvv())
if card := r.GetCard(); card != nil {
card.Pan = strings.TrimSpace(card.GetPan())
card.CardHolder = strings.TrimSpace(card.GetCardHolder())
card.Cvv = strings.TrimSpace(card.GetCvv())
r.Card = card
}
return r
}
func buildCardPayoutRequest(projectID int64, req *mntxv1.CardPayoutRequest) monetix.CardPayoutRequest {
card := monetix.Card{
PAN: req.GetCardPan(),
Year: int(req.GetCardExpYear()),
Month: int(req.GetCardExpMonth()),
CardHolder: req.GetCardHolder(),
}
return monetix.CardPayoutRequest{
General: monetix.General{
ProjectID: projectID,
PaymentID: req.GetPayoutId(),
},
Customer: monetix.Customer{
ID: req.GetCustomerId(),
FirstName: req.GetCustomerFirstName(),
Middle: req.GetCustomerMiddleName(),
LastName: req.GetCustomerLastName(),
IP: req.GetCustomerIp(),
Zip: req.GetCustomerZip(),
Country: req.GetCustomerCountry(),
State: req.GetCustomerState(),
City: req.GetCustomerCity(),
Address: req.GetCustomerAddress(),
},
Payment: monetix.Payment{
Amount: req.GetAmountMinor(),
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
},
Card: card,
}
}
func buildCardTokenPayoutRequest(projectID int64, req *mntxv1.CardTokenPayoutRequest) monetix.CardTokenPayoutRequest {
return monetix.CardTokenPayoutRequest{
General: monetix.General{
ProjectID: projectID,
PaymentID: req.GetPayoutId(),
},
Customer: monetix.Customer{
ID: req.GetCustomerId(),
FirstName: req.GetCustomerFirstName(),
Middle: req.GetCustomerMiddleName(),
LastName: req.GetCustomerLastName(),
IP: req.GetCustomerIp(),
Zip: req.GetCustomerZip(),
Country: req.GetCustomerCountry(),
State: req.GetCustomerState(),
City: req.GetCustomerCity(),
Address: req.GetCustomerAddress(),
},
Payment: monetix.Payment{
Amount: req.GetAmountMinor(),
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
},
Token: monetix.Token{
CardToken: req.GetCardToken(),
CardHolder: req.GetCardHolder(),
MaskedPAN: req.GetMaskedPan(),
},
}
}
func buildCardTokenizeRequest(projectID int64, req *mntxv1.CardTokenizeRequest, card *tokenizeCardInput) monetix.CardTokenizeRequest {
tokenizeCard := monetix.CardTokenize{
PAN: card.pan,
Year: int(card.year),
Month: int(card.month),
CardHolder: card.holder,
CVV: card.cvv,
}
return monetix.CardTokenizeRequest{
General: monetix.General{
ProjectID: projectID,
PaymentID: req.GetRequestId(),
},
Customer: monetix.Customer{
ID: req.GetCustomerId(),
FirstName: req.GetCustomerFirstName(),
Middle: req.GetCustomerMiddleName(),
LastName: req.GetCustomerLastName(),
IP: req.GetCustomerIp(),
Zip: req.GetCustomerZip(),
Country: req.GetCustomerCountry(),
State: req.GetCustomerState(),
City: req.GetCustomerCity(),
Address: req.GetCustomerAddress(),
},
Card: tokenizeCard,
}
}

View File

@@ -0,0 +1,55 @@
package gateway
import (
"strings"
"sync"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/proto"
)
type cardPayoutStore struct {
mu sync.RWMutex
payouts map[string]*mntxv1.CardPayoutState
}
func newCardPayoutStore() *cardPayoutStore {
return &cardPayoutStore{
payouts: make(map[string]*mntxv1.CardPayoutState),
}
}
func (s *cardPayoutStore) Save(p *mntxv1.CardPayoutState) {
if p == nil {
return
}
key := strings.TrimSpace(p.GetPayoutId())
if key == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.payouts[key] = cloneCardPayoutState(p)
}
func (s *cardPayoutStore) Get(payoutID string) (*mntxv1.CardPayoutState, bool) {
id := strings.TrimSpace(payoutID)
if id == "" {
return nil, false
}
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.payouts[id]
return cloneCardPayoutState(val), ok
}
func cloneCardPayoutState(p *mntxv1.CardPayoutState) *mntxv1.CardPayoutState {
if p == nil {
return nil
}
cloned := proto.Clone(p)
if cp, ok := cloned.(*mntxv1.CardPayoutState); ok {
return cp
}
return nil
}

View File

@@ -0,0 +1,83 @@
package gateway
import (
"strconv"
"strings"
"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 validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg monetix.Config) error {
if req == nil {
return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
}
if strings.TrimSpace(req.GetPayoutId()) == "" {
return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id"))
}
if strings.TrimSpace(req.GetCustomerId()) == "" {
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
}
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
}
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
}
if strings.TrimSpace(req.GetCustomerIp()) == "" {
return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
}
if req.GetAmountMinor() <= 0 {
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor"))
}
currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency()))
if currency == "" {
return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency"))
}
if !cfg.CurrencyAllowed(currency) {
return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency"))
}
pan := strings.TrimSpace(req.GetCardPan())
if pan == "" {
return newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card_pan"))
}
if strings.TrimSpace(req.GetCardHolder()) == "" {
return newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card_holder"))
}
if err := validateCardExpiryFields(req.GetCardExpMonth(), req.GetCardExpYear()); err != nil {
return err
}
if cfg.RequireCustomerAddress {
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
}
if strings.TrimSpace(req.GetCustomerCity()) == "" {
return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
}
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
}
if strings.TrimSpace(req.GetCustomerZip()) == "" {
return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
}
}
return nil
}
func validateCardExpiryFields(month uint32, year uint32) error {
if month == 0 || month > 12 {
return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card_exp_month"))
}
yearStr := strconv.Itoa(int(year))
if len(yearStr) < 2 || year == 0 {
return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card_exp_year"))
}
return nil
}

View File

@@ -0,0 +1,311 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/timestamppb"
)
type cardPayoutProcessor struct {
logger mlogger.Logger
config monetix.Config
clock clockpkg.Clock
store *cardPayoutStore
httpClient *http.Client
producer msg.Producer
}
func newCardPayoutProcessor(logger mlogger.Logger, cfg monetix.Config, clock clockpkg.Clock, store *cardPayoutStore, client *http.Client, producer msg.Producer) *cardPayoutProcessor {
return &cardPayoutProcessor{
logger: logger.Named("card_payout_processor"),
config: cfg,
clock: clock,
store: store,
httpClient: client,
producer: producer,
}
}
func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if p == nil {
return nil, merrors.Internal("card payout processor not initialised")
}
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
return nil, merrors.Internal("monetix configuration is incomplete")
}
req = sanitizeCardPayoutRequest(req)
if err := validateCardPayoutRequest(req, p.config); err != nil {
return nil, err
}
projectID := req.GetProjectId()
if projectID == 0 {
projectID = p.config.ProjectID
}
if projectID == 0 {
return nil, merrors.Internal("monetix project_id is not configured")
}
now := timestamppb.New(p.clock.Now())
state := &mntxv1.CardPayoutState{
PayoutId: req.GetPayoutId(),
ProjectId: projectID,
CustomerId: req.GetCustomerId(),
AmountMinor: req.GetAmountMinor(),
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
CreatedAt: now,
UpdatedAt: now,
}
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
if existing.GetCreatedAt() != nil {
state.CreatedAt = existing.GetCreatedAt()
}
}
client := monetix.NewClient(p.config, p.httpClient, p.logger)
apiReq := buildCardPayoutRequest(projectID, req)
result, err := client.CreateCardPayout(ctx, apiReq)
if err != nil {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
state.ProviderMessage = err.Error()
state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state)
return nil, err
}
state.ProviderPaymentId = result.ProviderRequestID
if result.Accepted {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
} else {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
state.ProviderCode = result.ErrorCode
state.ProviderMessage = result.ErrorMessage
}
state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state)
resp := &mntxv1.CardPayoutResponse{
Payout: state,
Accepted: result.Accepted,
ProviderRequestId: result.ProviderRequestID,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
return resp, nil
}
func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
if p == nil {
return nil, merrors.Internal("card payout processor not initialised")
}
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
return nil, merrors.Internal("monetix configuration is incomplete")
}
req = sanitizeCardTokenPayoutRequest(req)
if err := validateCardTokenPayoutRequest(req, p.config); err != nil {
return nil, err
}
projectID := req.GetProjectId()
if projectID == 0 {
projectID = p.config.ProjectID
}
if projectID == 0 {
return nil, merrors.Internal("monetix project_id is not configured")
}
now := timestamppb.New(p.clock.Now())
state := &mntxv1.CardPayoutState{
PayoutId: req.GetPayoutId(),
ProjectId: projectID,
CustomerId: req.GetCustomerId(),
AmountMinor: req.GetAmountMinor(),
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
CreatedAt: now,
UpdatedAt: now,
}
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
if existing.GetCreatedAt() != nil {
state.CreatedAt = existing.GetCreatedAt()
}
}
client := monetix.NewClient(p.config, p.httpClient, p.logger)
apiReq := buildCardTokenPayoutRequest(projectID, req)
result, err := client.CreateCardTokenPayout(ctx, apiReq)
if err != nil {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
state.ProviderMessage = err.Error()
state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state)
return nil, err
}
state.ProviderPaymentId = result.ProviderRequestID
if result.Accepted {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
} else {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
state.ProviderCode = result.ErrorCode
state.ProviderMessage = result.ErrorMessage
}
state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state)
resp := &mntxv1.CardTokenPayoutResponse{
Payout: state,
Accepted: result.Accepted,
ProviderRequestId: result.ProviderRequestID,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
return resp, nil
}
func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) {
if p == nil {
return nil, merrors.Internal("card payout processor not initialised")
}
cardInput, err := validateCardTokenizeRequest(req, p.config)
if err != nil {
return nil, err
}
projectID := req.GetProjectId()
if projectID == 0 {
projectID = p.config.ProjectID
}
if projectID == 0 {
return nil, merrors.Internal("monetix project_id is not configured")
}
req = sanitizeCardTokenizeRequest(req)
cardInput = extractTokenizeCard(req)
client := monetix.NewClient(p.config, p.httpClient, p.logger)
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
result, err := client.CreateCardTokenization(ctx, apiReq)
if err != nil {
return nil, err
}
resp := &mntxv1.CardTokenizeResponse{
RequestId: req.GetRequestId(),
Success: result.Accepted,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
resp.Token = result.Token
resp.MaskedPan = result.MaskedPAN
resp.ExpiryMonth = result.ExpiryMonth
resp.ExpiryYear = result.ExpiryYear
resp.CardBrand = result.CardBrand
return resp, nil
}
func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv1.CardPayoutState, error) {
if p == nil {
return nil, merrors.Internal("card payout processor not initialised")
}
id := strings.TrimSpace(payoutID)
if id == "" {
return nil, merrors.InvalidArgument("payout_id is required", "payout_id")
}
state, ok := p.store.Get(id)
if !ok || state == nil {
return nil, merrors.NoData("payout not found")
}
return state, nil
}
func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byte) (int, error) {
if p == nil {
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
}
if len(payload) == 0 {
return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty")
}
if strings.TrimSpace(p.config.SecretKey) == "" {
return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
}
var cb monetixCallback
if err := json.Unmarshal(payload, &cb); err != nil {
return http.StatusBadRequest, err
}
if strings.TrimSpace(cb.Signature) == "" {
return http.StatusBadRequest, merrors.InvalidArgument("signature is missing")
}
if err := verifyCallbackSignature(cb, p.config.SecretKey); err != nil {
p.logger.Warn("Monetix callback signature check failed", zap.Error(err))
return http.StatusForbidden, err
}
state, statusLabel := mapCallbackToState(p.clock, p.config, cb)
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
if existing.GetCreatedAt() != nil {
state.CreatedAt = existing.GetCreatedAt()
}
}
p.store.Save(state)
p.emitCardPayoutEvent(state)
monetix.ObserveCallback(statusLabel)
p.logger.Info("Monetix payout callback processed",
zap.String("payout_id", state.GetPayoutId()),
zap.String("status", statusLabel),
zap.String("provider_code", state.GetProviderCode()),
zap.String("provider_message", state.GetProviderMessage()),
zap.String("masked_account", cb.Account.Number),
)
return http.StatusOK, nil
}
func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState) {
if state == nil || p.producer == nil {
return
}
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))
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))
return
}
if err := p.producer.SendMessage(env); err != nil {
p.logger.Warn("failed to publish payout callback event", zap.Error(err))
}
}

View File

@@ -0,0 +1,63 @@
package gateway
import (
"strings"
"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 validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg monetix.Config) error {
if req == nil {
return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
}
if strings.TrimSpace(req.GetPayoutId()) == "" {
return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id"))
}
if strings.TrimSpace(req.GetCustomerId()) == "" {
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
}
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
}
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
}
if strings.TrimSpace(req.GetCustomerIp()) == "" {
return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
}
if req.GetAmountMinor() <= 0 {
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor"))
}
currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency()))
if currency == "" {
return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency"))
}
if !cfg.CurrencyAllowed(currency) {
return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency"))
}
if strings.TrimSpace(req.GetCardToken()) == "" {
return newPayoutError("missing_card_token", merrors.InvalidArgument("card_token is required", "card_token"))
}
if cfg.RequireCustomerAddress {
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
}
if strings.TrimSpace(req.GetCustomerCity()) == "" {
return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
}
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
}
if strings.TrimSpace(req.GetCustomerZip()) == "" {
return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
}
}
return nil
}

View File

@@ -0,0 +1,108 @@
package gateway
import (
"strings"
"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"
)
type tokenizeCardInput struct {
pan string
month uint32
year uint32
holder string
cvv string
}
func validateCardTokenizeRequest(req *mntxv1.CardTokenizeRequest, cfg monetix.Config) (*tokenizeCardInput, error) {
if req == nil {
return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
}
if strings.TrimSpace(req.GetRequestId()) == "" {
return nil, newPayoutError("missing_request_id", merrors.InvalidArgument("request_id is required", "request_id"))
}
if strings.TrimSpace(req.GetCustomerId()) == "" {
return nil, newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
}
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
return nil, newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
}
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
return nil, newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
}
if strings.TrimSpace(req.GetCustomerIp()) == "" {
return nil, newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
}
card := extractTokenizeCard(req)
if card.pan == "" {
return nil, newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card.pan"))
}
if card.holder == "" {
return nil, newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card.holder"))
}
if card.month == 0 || card.month > 12 {
return nil, newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card.exp_month"))
}
if card.year == 0 {
return nil, newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card.exp_year"))
}
if card.cvv == "" {
return nil, newPayoutError("missing_cvv", merrors.InvalidArgument("card_cvv is required", "card.cvv"))
}
if expired(card.month, card.year) {
return nil, newPayoutError("expired_card", merrors.InvalidArgument("card expiry is in the past", "card.expiry"))
}
if cfg.RequireCustomerAddress {
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
return nil, newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
}
if strings.TrimSpace(req.GetCustomerCity()) == "" {
return nil, newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
}
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
return nil, newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
}
if strings.TrimSpace(req.GetCustomerZip()) == "" {
return nil, newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
}
}
return card, nil
}
func extractTokenizeCard(req *mntxv1.CardTokenizeRequest) *tokenizeCardInput {
card := req.GetCard()
if card != nil {
return &tokenizeCardInput{
pan: strings.TrimSpace(card.GetPan()),
month: card.GetExpMonth(),
year: card.GetExpYear(),
holder: strings.TrimSpace(card.GetCardHolder()),
cvv: strings.TrimSpace(card.GetCvv()),
}
}
return &tokenizeCardInput{
pan: strings.TrimSpace(req.GetCardPan()),
month: req.GetCardExpMonth(),
year: req.GetCardExpYear(),
holder: strings.TrimSpace(req.GetCardHolder()),
cvv: strings.TrimSpace(req.GetCardCvv()),
}
}
func expired(month uint32, year uint32) bool {
now := time.Now()
y := int(year)
m := time.Month(month)
// Normalize 2-digit years: assume 2000-2099.
if y < 100 {
y += 2000
}
expiry := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, -1)
return now.After(expiry)
}

View File

@@ -0,0 +1,174 @@
package gateway
import (
"errors"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
var (
metricsOnce sync.Once
rpcLatency *prometheus.HistogramVec
rpcStatus *prometheus.CounterVec
payoutCounter *prometheus.CounterVec
payoutAmountTotal *prometheus.CounterVec
payoutErrorCount *prometheus.CounterVec
payoutMissedAmounts *prometheus.CounterVec
)
func initMetrics() {
metricsOnce.Do(func() {
rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "rpc_latency_seconds",
Help: "Latency distribution for Monetix gateway RPC handlers.",
Buckets: prometheus.DefBuckets,
}, []string{"method"})
rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "rpc_requests_total",
Help: "Total number of RPC invocations grouped by method and status.",
}, []string{"method", "status"})
payoutCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "payouts_total",
Help: "Total payouts processed grouped by outcome.",
}, []string{"status"})
payoutAmountTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "payout_amount_total",
Help: "Total payout amount grouped by outcome and currency.",
}, []string{"status", "currency"})
payoutErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "payout_errors_total",
Help: "Payout failures grouped by reason.",
}, []string{"reason"})
payoutMissedAmounts = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "payout_missed_amount_total",
Help: "Total payout volume that failed grouped by reason and currency.",
}, []string{"reason", "currency"})
})
}
func observeRPC(method string, err error, duration time.Duration) {
if rpcLatency != nil {
rpcLatency.WithLabelValues(method).Observe(duration.Seconds())
}
if rpcStatus != nil {
rpcStatus.WithLabelValues(method, statusLabel(err)).Inc()
}
}
func observePayoutSuccess(amount *moneyv1.Money) {
if payoutCounter != nil {
payoutCounter.WithLabelValues("processed").Inc()
}
value, currency := monetaryValue(amount)
if value > 0 && payoutAmountTotal != nil {
payoutAmountTotal.WithLabelValues("processed", currency).Add(value)
}
}
func observePayoutError(reason string, amount *moneyv1.Money) {
reason = reasonLabel(reason)
if payoutCounter != nil {
payoutCounter.WithLabelValues("failed").Inc()
}
if payoutErrorCount != nil {
payoutErrorCount.WithLabelValues(reason).Inc()
}
value, currency := monetaryValue(amount)
if value <= 0 {
return
}
if payoutAmountTotal != nil {
payoutAmountTotal.WithLabelValues("failed", currency).Add(value)
}
if payoutMissedAmounts != nil {
payoutMissedAmounts.WithLabelValues(reason, currency).Add(value)
}
}
func monetaryValue(amount *moneyv1.Money) (float64, string) {
if amount == nil {
return 0, "unknown"
}
val := strings.TrimSpace(amount.Amount)
if val == "" {
return 0, currencyLabel(amount.Currency)
}
dec, err := decimal.NewFromString(val)
if err != nil {
return 0, currencyLabel(amount.Currency)
}
f, _ := dec.Float64()
if f < 0 {
return 0, currencyLabel(amount.Currency)
}
return f, currencyLabel(amount.Currency)
}
func currencyLabel(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return "unknown"
}
return code
}
func reasonLabel(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return "unknown"
}
return strings.ToLower(reason)
}
func statusLabel(err error) string {
switch {
case err == nil:
return "ok"
case errors.Is(err, merrors.ErrInvalidArg):
return "invalid_argument"
case errors.Is(err, merrors.ErrNoData):
return "not_found"
case errors.Is(err, merrors.ErrDataConflict):
return "conflict"
case errors.Is(err, merrors.ErrAccessDenied):
return "denied"
case errors.Is(err, merrors.ErrInternal):
return "internal"
default:
return "error"
}
}
func normalizeCallbackStatus(status string) string {
status = strings.TrimSpace(status)
if status == "" {
return "unknown"
}
return strings.ToLower(status)
}

View File

@@ -0,0 +1,44 @@
package gateway
import (
"net/http"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/pkg/clock"
msg "github.com/tech/sendico/pkg/messaging"
)
// Option configures optional service dependencies.
type Option func(*Service)
// WithClock injects a custom clock (useful for tests).
func WithClock(c clock.Clock) Option {
return func(s *Service) {
if c != nil {
s.clock = c
}
}
}
// WithProducer attaches a messaging producer to the service.
func WithProducer(p msg.Producer) Option {
return func(s *Service) {
s.producer = p
}
}
// WithHTTPClient injects a custom HTTP client (useful for tests).
func WithHTTPClient(client *http.Client) Option {
return func(s *Service) {
if client != nil {
s.httpClient = client
}
}
}
// WithMonetixConfig sets the Monetix connectivity options.
func WithMonetixConfig(cfg monetix.Config) Option {
return func(s *Service) {
s.config = cfg
}
}

View File

@@ -0,0 +1,30 @@
package gateway
import (
"context"
"fmt"
"strings"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
return executeUnary(ctx, s, "GetPayout", s.handleGetPayout, req)
}
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
ref := strings.TrimSpace(req.GetPayoutRef())
if 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 {
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
}
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
}

View File

@@ -0,0 +1,46 @@
package gateway
import (
"sync"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/proto"
)
type payoutStore struct {
mu sync.RWMutex
payouts map[string]*mntxv1.Payout
}
func newPayoutStore() *payoutStore {
return &payoutStore{
payouts: make(map[string]*mntxv1.Payout),
}
}
func (s *payoutStore) Save(p *mntxv1.Payout) {
if p == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.payouts[p.GetPayoutRef()] = clonePayout(p)
}
func (s *payoutStore) Get(ref string) (*mntxv1.Payout, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.payouts[ref]
return clonePayout(p), ok
}
func clonePayout(p *mntxv1.Payout) *mntxv1.Payout {
if p == nil {
return nil
}
cloned := proto.Clone(p)
if cp, ok := cloned.(*mntxv1.Payout); ok {
return cp
}
return nil
}

View File

@@ -0,0 +1,131 @@
package gateway
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequest) (*mntxv1.SubmitPayoutResponse, error) {
return executeUnary(ctx, s, "SubmitPayout", s.handleSubmitPayout, req)
}
func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] {
payout, err := s.buildPayout(req)
if err != nil {
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
}
s.store.Save(payout)
s.emitEvent(payout, nm.NAPending)
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason()))
return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout})
}
func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout, error) {
if req == nil {
return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
}
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
if idempotencyKey == "" {
return nil, newPayoutError("missing_idempotency_key", merrors.InvalidArgument("idempotency_key is required", "idempotency_key"))
}
orgRef := strings.TrimSpace(req.OrganizationRef)
if orgRef == "" {
return nil, newPayoutError("missing_organization_ref", merrors.InvalidArgument("organization_ref is required", "organization_ref"))
}
if err := validateAmount(req.Amount); err != nil {
return nil, err
}
if err := validateDestination(req.Destination); err != nil {
return nil, err
}
if reason := strings.TrimSpace(req.SimulatedFailureReason); reason != "" {
return nil, newPayoutError(normalizeReason(reason), merrors.InvalidArgument("simulated payout failure requested"))
}
now := timestamppb.New(s.clock.Now())
payout := &mntxv1.Payout{
PayoutRef: newPayoutRef(),
IdempotencyKey: idempotencyKey,
OrganizationRef: orgRef,
Destination: req.Destination,
Amount: req.Amount,
Description: strings.TrimSpace(req.Description),
Metadata: req.Metadata,
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
CreatedAt: now,
UpdatedAt: now,
}
return payout, nil
}
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
outcome := clonePayout(original)
if outcome == nil {
return
}
// Simulate async processing delay for realism.
time.Sleep(150 * time.Millisecond)
outcome.UpdatedAt = timestamppb.New(s.clock.Now())
if simulatedFailure != "" {
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
outcome.FailureReason = simulatedFailure
observePayoutError(simulatedFailure, outcome.Amount)
s.store.Save(outcome)
s.emitEvent(outcome, nm.NAUpdated)
return
}
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
observePayoutSuccess(outcome.Amount)
s.store.Save(outcome)
s.emitEvent(outcome, nm.NAUpdated)
}
func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) {
if payout == nil || s.producer == nil {
return
}
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
if err != nil {
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))
return
}
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("failed to publish payout event", zapError(err))
}
}
func zapError(err error) zap.Field {
return zap.Error(err)
}

View File

@@ -0,0 +1,106 @@
package gateway
import (
"strconv"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func validateAmount(amount *moneyv1.Money) error {
if amount == nil {
return newPayoutError("missing_amount", merrors.InvalidArgument("amount is required", "amount"))
}
if strings.TrimSpace(amount.Currency) == "" {
return newPayoutError("missing_currency", merrors.InvalidArgument("amount currency is required", "amount.currency"))
}
val := strings.TrimSpace(amount.Amount)
if val == "" {
return newPayoutError("missing_amount_value", merrors.InvalidArgument("amount value is required", "amount.amount"))
}
dec, err := decimal.NewFromString(val)
if err != nil {
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount must be a decimal value", "amount.amount"))
}
if dec.Sign() <= 0 {
return newPayoutError("non_positive_amount", merrors.InvalidArgument("amount must be positive", "amount.amount"))
}
return nil
}
func validateDestination(dest *mntxv1.PayoutDestination) error {
if dest == nil {
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
}
if bank := dest.GetBankAccount(); bank != nil {
return validateBankAccount(bank)
}
if card := dest.GetCard(); card != nil {
return validateCardDestination(card)
}
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include bank_account or card", "destination"))
}
func validateBankAccount(dest *mntxv1.BankAccount) error {
if dest == nil {
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
}
iban := strings.TrimSpace(dest.Iban)
holder := strings.TrimSpace(dest.AccountHolder)
if iban == "" && holder == "" {
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include iban or account_holder", "destination"))
}
return nil
}
func validateCardDestination(card *mntxv1.CardDestination) error {
if card == nil {
return newPayoutError("missing_destination", merrors.InvalidArgument("destination.card is required", "destination.card"))
}
pan := strings.TrimSpace(card.GetPan())
token := strings.TrimSpace(card.GetToken())
if pan == "" && token == "" {
return newPayoutError("invalid_card_destination", merrors.InvalidArgument("card destination must include pan or token", "destination.card"))
}
if strings.TrimSpace(card.GetCardholderName()) == "" {
return newPayoutError("missing_cardholder_name", merrors.InvalidArgument("cardholder_name is required", "destination.card.cardholder_name"))
}
month := strings.TrimSpace(card.GetExpMonth())
year := strings.TrimSpace(card.GetExpYear())
if pan != "" {
if err := validateExpiry(month, year); err != nil {
return err
}
}
return nil
}
func validateExpiry(month, year string) error {
if month == "" || year == "" {
return newPayoutError("missing_expiry", merrors.InvalidArgument("exp_month and exp_year are required for card payouts", "destination.card.expiry"))
}
m, err := strconv.Atoi(month)
if err != nil || m < 1 || m > 12 {
return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("exp_month must be between 01 and 12", "destination.card.exp_month"))
}
if _, err := strconv.Atoi(year); err != nil || len(year) < 2 {
return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("exp_year must be numeric", "destination.card.exp_year"))
}
return nil
}

View File

@@ -0,0 +1,119 @@
package gateway
import (
"context"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/grpc"
)
type Service struct {
logger mlogger.Logger
clock clockpkg.Clock
producer msg.Producer
store *payoutStore
cardStore *cardPayoutStore
config monetix.Config
httpClient *http.Client
card *cardPayoutProcessor
mntxv1.UnimplementedMntxGatewayServiceServer
}
type payoutFailure interface {
error
Reason() string
}
type reasonedError struct {
reason string
err error
}
func (r reasonedError) Error() string {
return r.err.Error()
}
func (r reasonedError) Unwrap() error {
return r.err
}
func (r reasonedError) Reason() string {
return r.reason
}
// NewService constructs the Monetix gateway service skeleton.
func NewService(logger mlogger.Logger, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("service"),
clock: clockpkg.NewSystem(),
store: newPayoutStore(),
cardStore: newCardPayoutStore(),
config: monetix.DefaultConfig(),
}
initMetrics()
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
if svc.clock == nil {
svc.clock = clockpkg.NewSystem()
}
if svc.httpClient == nil {
svc.httpClient = &http.Client{Timeout: svc.config.Timeout()}
} else if svc.httpClient.Timeout <= 0 {
svc.httpClient.Timeout = svc.config.Timeout()
}
if svc.cardStore == nil {
svc.cardStore = newCardPayoutStore()
}
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer)
return svc
}
// Register wires the service onto the provided gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
mntxv1.RegisterMntxGatewayServiceServer(reg, s)
})
}
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) {
start := svc.clock.Now()
resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req)
observeRPC(method, err, svc.clock.Now().Sub(start))
return resp, err
}
func newPayoutRef() string {
return "pyt_" + strings.ReplaceAll(uuid.New().String(), "-", "")
}
func normalizeReason(reason string) string {
return strings.ToLower(strings.TrimSpace(reason))
}
func newPayoutError(reason string, err error) error {
return reasonedError{
reason: normalizeReason(reason),
err: err,
}
}

View File

@@ -0,0 +1,66 @@
package monetix
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Client struct {
cfg Config
client *http.Client
logger mlogger.Logger
}
func NewClient(cfg Config, httpClient *http.Client, logger mlogger.Logger) *Client {
client := httpClient
if client == nil {
client = &http.Client{Timeout: cfg.timeout()}
}
cl := logger
if cl == nil {
cl = zap.NewNop()
}
return &Client{
cfg: cfg,
client: client,
logger: cl.Named("monetix_client"),
}
}
func (c *Client) CreateCardPayout(ctx context.Context, req CardPayoutRequest) (*CardPayoutSendResult, error) {
return c.sendCardPayout(ctx, req)
}
func (c *Client) CreateCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) {
return c.sendCardTokenPayout(ctx, req)
}
func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) {
return c.sendTokenization(ctx, req)
}
func signPayload(payload any, secret string) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
h := hmac.New(sha256.New, []byte(secret))
if _, err := h.Write(data); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// SignPayload exposes signature calculation for callback verification.
func SignPayload(payload any, secret string) (string, error) {
return signPayload(payload, secret)
}

View File

@@ -0,0 +1,78 @@
package monetix
import (
"strings"
"time"
)
const (
DefaultRequestTimeout = 15 * time.Second
DefaultStatusSuccess = "success"
DefaultStatusProcessing = "processing"
OutcomeSuccess = "success"
OutcomeProcessing = "processing"
OutcomeDecline = "decline"
)
// Config holds resolved settings for communicating with Monetix.
type Config struct {
BaseURL string
ProjectID int64
SecretKey string
AllowedCurrencies []string
RequireCustomerAddress bool
RequestTimeout time.Duration
StatusSuccess string
StatusProcessing string
}
func DefaultConfig() Config {
return Config{
RequestTimeout: DefaultRequestTimeout,
StatusSuccess: DefaultStatusSuccess,
StatusProcessing: DefaultStatusProcessing,
}
}
func (c Config) timeout() time.Duration {
if c.RequestTimeout <= 0 {
return DefaultRequestTimeout
}
return c.RequestTimeout
}
// Timeout exposes the configured HTTP timeout for external callers.
func (c Config) Timeout() time.Duration {
return c.timeout()
}
func (c Config) CurrencyAllowed(code string) bool {
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return false
}
if len(c.AllowedCurrencies) == 0 {
return true
}
for _, allowed := range c.AllowedCurrencies {
if strings.EqualFold(strings.TrimSpace(allowed), code) {
return true
}
}
return false
}
func (c Config) SuccessStatus() string {
if strings.TrimSpace(c.StatusSuccess) == "" {
return DefaultStatusSuccess
}
return strings.ToLower(strings.TrimSpace(c.StatusSuccess))
}
func (c Config) ProcessingStatus() string {
if strings.TrimSpace(c.StatusProcessing) == "" {
return DefaultStatusProcessing
}
return strings.ToLower(strings.TrimSpace(c.StatusProcessing))
}

View File

@@ -0,0 +1,21 @@
package monetix
import "strings"
// MaskPAN redacts a primary account number by keeping the first 6 and last 4 digits.
func MaskPAN(pan string) string {
p := strings.TrimSpace(pan)
if len(p) <= 4 {
return strings.Repeat("*", len(p))
}
if len(p) <= 10 {
return p[:2] + strings.Repeat("*", len(p)-4) + p[len(p)-2:]
}
maskLen := len(p) - 10
if maskLen < 0 {
maskLen = 0
}
return p[:6] + strings.Repeat("*", maskLen) + p[len(p)-4:]
}

View File

@@ -0,0 +1,71 @@
package monetix
import (
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
metricsOnce sync.Once
cardPayoutRequests *prometheus.CounterVec
cardPayoutCallbacks *prometheus.CounterVec
cardPayoutLatency *prometheus.HistogramVec
)
func initMetrics() {
metricsOnce.Do(func() {
cardPayoutRequests = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "card_payout_requests_total",
Help: "Monetix card payout submissions grouped by outcome.",
}, []string{"outcome"})
cardPayoutCallbacks = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "card_payout_callbacks_total",
Help: "Monetix card payout callbacks grouped by provider status.",
}, []string{"status"})
cardPayoutLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "card_payout_request_latency_seconds",
Help: "Latency distribution for outbound Monetix card payout requests.",
Buckets: prometheus.DefBuckets,
}, []string{"outcome"})
})
}
func observeRequest(outcome string, duration time.Duration) {
initMetrics()
outcome = strings.ToLower(strings.TrimSpace(outcome))
if outcome == "" {
outcome = "unknown"
}
if cardPayoutLatency != nil {
cardPayoutLatency.WithLabelValues(outcome).Observe(duration.Seconds())
}
if cardPayoutRequests != nil {
cardPayoutRequests.WithLabelValues(outcome).Inc()
}
}
// ObserveCallback records callback status for Monetix card payouts.
func ObserveCallback(status string) {
initMetrics()
status = strings.TrimSpace(status)
if status == "" {
status = "unknown"
}
status = strings.ToLower(status)
if cardPayoutCallbacks != nil {
cardPayoutCallbacks.WithLabelValues(status).Inc()
}
}

View File

@@ -0,0 +1,96 @@
package monetix
type General struct {
ProjectID int64 `json:"project_id"`
PaymentID string `json:"payment_id"`
Signature string `json:"signature,omitempty"`
}
type Customer struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
Middle string `json:"middle_name,omitempty"`
LastName string `json:"last_name"`
IP string `json:"ip_address"`
Zip string `json:"zip,omitempty"`
Country string `json:"country,omitempty"`
State string `json:"state,omitempty"`
City string `json:"city,omitempty"`
Address string `json:"address,omitempty"`
}
type Payment struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
type Card struct {
PAN string `json:"pan"`
Year int `json:"year,omitempty"`
Month int `json:"month,omitempty"`
CardHolder string `json:"card_holder"`
}
type CardTokenize struct {
PAN string `json:"pan"`
Year int `json:"year,omitempty"`
Month int `json:"month,omitempty"`
CardHolder string `json:"card_holder"`
CVV string `json:"cvv,omitempty"`
}
type Token struct {
CardToken string `json:"card_token"`
CardHolder string `json:"card_holder,omitempty"`
MaskedPAN string `json:"masked_pan,omitempty"`
}
type CardPayoutRequest struct {
General General `json:"general"`
Customer Customer `json:"customer"`
Payment Payment `json:"payment"`
Card Card `json:"card"`
}
type CardTokenPayoutRequest struct {
General General `json:"general"`
Customer Customer `json:"customer"`
Payment Payment `json:"payment"`
Token Token `json:"token"`
}
type CardTokenizeRequest struct {
General General `json:"general"`
Customer Customer `json:"customer"`
Card CardTokenize `json:"card"`
}
type CardPayoutSendResult struct {
Accepted bool
ProviderRequestID string
StatusCode int
ErrorCode string
ErrorMessage string
}
type TokenizationResult struct {
CardPayoutSendResult
Token string
MaskedPAN string
ExpiryMonth string
ExpiryYear string
CardBrand string
}
type APIResponse struct {
RequestID string `json:"request_id"`
Message string `json:"message"`
Code string `json:"code"`
Operation struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"operation"`
}

View File

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