Files
sendico/api/gateway/mntx/internal/service/gateway/card_processor.go
2025-12-26 12:26:28 +01:00

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