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) == "" { 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, } 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) == "" { 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, } 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 { 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 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 == "" { 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") } 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 { 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.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)) } }