392 lines
13 KiB
Go
392 lines
13 KiB
Go
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")
|
|
}
|
|
p.logger.Info("Submitting card payout",
|
|
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
|
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
zap.Int64("amount_minor", req.GetAmountMinor()),
|
|
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
|
)
|
|
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
|
p.logger.Warn("Monetix configuration is incomplete for payout submission")
|
|
return nil, merrors.Internal("monetix configuration is incomplete")
|
|
}
|
|
|
|
req = sanitizeCardPayoutRequest(req)
|
|
if err := validateCardPayoutRequest(req, p.config); err != nil {
|
|
p.logger.Warn("Card payout validation failed",
|
|
zap.String("payout_id", req.GetPayoutId()),
|
|
zap.String("customer_id", req.GetCustomerId()),
|
|
zap.Error(err),
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
projectID := req.GetProjectId()
|
|
if projectID == 0 {
|
|
projectID = p.config.ProjectID
|
|
}
|
|
if projectID == 0 {
|
|
p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
|
return nil, merrors.Internal("monetix project_id is not configured")
|
|
}
|
|
|
|
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)
|
|
p.logger.Warn("Monetix payout submission failed",
|
|
zap.String("payout_id", req.GetPayoutId()),
|
|
zap.String("customer_id", req.GetCustomerId()),
|
|
zap.Error(err),
|
|
)
|
|
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,
|
|
}
|
|
|
|
p.logger.Info("Card payout submission stored",
|
|
zap.String("payout_id", state.GetPayoutId()),
|
|
zap.String("status", state.GetStatus().String()),
|
|
zap.Bool("accepted", result.Accepted),
|
|
zap.String("provider_request_id", result.ProviderRequestID),
|
|
)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
p.logger.Info("Submitting card token payout",
|
|
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
|
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
zap.Int64("amount_minor", req.GetAmountMinor()),
|
|
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
|
)
|
|
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
|
p.logger.Warn("Monetix configuration is incomplete for token payout submission")
|
|
return nil, merrors.Internal("monetix configuration is incomplete")
|
|
}
|
|
|
|
req = sanitizeCardTokenPayoutRequest(req)
|
|
if err := validateCardTokenPayoutRequest(req, p.config); err != nil {
|
|
p.logger.Warn("Card token payout validation failed",
|
|
zap.String("payout_id", req.GetPayoutId()),
|
|
zap.String("customer_id", req.GetCustomerId()),
|
|
zap.Error(err),
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
projectID := req.GetProjectId()
|
|
if projectID == 0 {
|
|
projectID = p.config.ProjectID
|
|
}
|
|
if projectID == 0 {
|
|
p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
|
return nil, merrors.Internal("monetix project_id is not configured")
|
|
}
|
|
|
|
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)
|
|
p.logger.Warn("Monetix token payout submission failed",
|
|
zap.String("payout_id", req.GetPayoutId()),
|
|
zap.String("customer_id", req.GetCustomerId()),
|
|
zap.Error(err),
|
|
)
|
|
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,
|
|
}
|
|
|
|
p.logger.Info("Card token payout submission stored",
|
|
zap.String("payout_id", state.GetPayoutId()),
|
|
zap.String("status", state.GetStatus().String()),
|
|
zap.Bool("accepted", result.Accepted),
|
|
zap.String("provider_request_id", result.ProviderRequestID),
|
|
)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
p.logger.Info("Submitting card tokenization",
|
|
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
|
|
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
|
)
|
|
cardInput, err := validateCardTokenizeRequest(req, p.config)
|
|
if err != nil {
|
|
p.logger.Warn("Card tokenization validation failed",
|
|
zap.String("request_id", req.GetRequestId()),
|
|
zap.String("customer_id", req.GetCustomerId()),
|
|
zap.Error(err),
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
projectID := req.GetProjectId()
|
|
if projectID == 0 {
|
|
projectID = p.config.ProjectID
|
|
}
|
|
if projectID == 0 {
|
|
p.logger.Warn("Monetix project_id is not configured", zap.String("request_id", req.GetRequestId()))
|
|
return nil, merrors.Internal("monetix project_id is not configured")
|
|
}
|
|
|
|
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 {
|
|
p.logger.Warn("Monetix tokenization request failed",
|
|
zap.String("request_id", req.GetRequestId()),
|
|
zap.String("customer_id", req.GetCustomerId()),
|
|
zap.Error(err),
|
|
)
|
|
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
|
|
|
|
p.logger.Info("Card tokenization completed",
|
|
zap.String("request_id", resp.GetRequestId()),
|
|
zap.Bool("success", resp.GetSuccess()),
|
|
zap.String("provider_request_id", result.ProviderRequestID),
|
|
)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
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)
|
|
p.logger.Info("Card payout status requested", zap.String("payout_id", id))
|
|
if id == "" {
|
|
p.logger.Warn("Payout status requested with empty payout_id")
|
|
return nil, merrors.InvalidArgument("payout_id is required", "payout_id")
|
|
}
|
|
|
|
state, ok := p.store.Get(id)
|
|
if !ok || state == nil {
|
|
p.logger.Warn("Payout status not found", zap.String("payout_id", id))
|
|
return nil, merrors.NoData("payout not found")
|
|
}
|
|
p.logger.Info("Card payout status resolved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
|
return state, nil
|
|
}
|
|
|
|
func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byte) (int, error) {
|
|
if p == nil {
|
|
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
|
}
|
|
p.logger.Debug("Processing Monetix callback", zap.Int("payload_bytes", len(payload)))
|
|
if len(payload) == 0 {
|
|
p.logger.Warn("Received empty Monetix callback payload")
|
|
return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty")
|
|
}
|
|
if strings.TrimSpace(p.config.SecretKey) == "" {
|
|
p.logger.Warn("Monetix secret key is not configured; cannot verify callback")
|
|
return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
|
|
}
|
|
|
|
var cb monetixCallback
|
|
if err := json.Unmarshal(payload, &cb); err != nil {
|
|
p.logger.Warn("Failed to unmarshal Monetix callback", zap.Error(err))
|
|
return http.StatusBadRequest, err
|
|
}
|
|
|
|
if strings.TrimSpace(cb.Signature) == "" {
|
|
p.logger.Warn("Monetix callback signature is missing", zap.String("payout_id", cb.Payment.ID))
|
|
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.Debug("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))
|
|
}
|
|
}
|