monetix gateway
This commit is contained in:
134
api/gateway/mntx/internal/service/gateway/callback.go
Normal file
134
api/gateway/mntx/internal/service/gateway/callback.go
Normal 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)))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
311
api/gateway/mntx/internal/service/gateway/card_processor.go
Normal file
311
api/gateway/mntx/internal/service/gateway/card_processor.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
174
api/gateway/mntx/internal/service/gateway/metrics.go
Normal file
174
api/gateway/mntx/internal/service/gateway/metrics.go
Normal 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)
|
||||
}
|
||||
44
api/gateway/mntx/internal/service/gateway/options.go
Normal file
44
api/gateway/mntx/internal/service/gateway/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
30
api/gateway/mntx/internal/service/gateway/payout_get.go
Normal file
30
api/gateway/mntx/internal/service/gateway/payout_get.go
Normal 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})
|
||||
}
|
||||
46
api/gateway/mntx/internal/service/gateway/payout_store.go
Normal file
46
api/gateway/mntx/internal/service/gateway/payout_store.go
Normal 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
|
||||
}
|
||||
131
api/gateway/mntx/internal/service/gateway/payout_submit.go
Normal file
131
api/gateway/mntx/internal/service/gateway/payout_submit.go
Normal 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)
|
||||
}
|
||||
106
api/gateway/mntx/internal/service/gateway/payout_validation.go
Normal file
106
api/gateway/mntx/internal/service/gateway/payout_validation.go
Normal 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
|
||||
}
|
||||
119
api/gateway/mntx/internal/service/gateway/service.go
Normal file
119
api/gateway/mntx/internal/service/gateway/service.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user