refactored payment orchestration
This commit is contained in:
@@ -15,7 +15,10 @@ import (
|
||||
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/gateway/mntx/storage"
|
||||
gatewaymongo "github.com/tech/sendico/gateway/mntx/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
@@ -31,7 +34,7 @@ type Imp struct {
|
||||
debug bool
|
||||
|
||||
config *config
|
||||
app *grpcapp.App[struct{}]
|
||||
app *grpcapp.App[storage.Repository]
|
||||
http *http.Server
|
||||
service *mntxservice.Service
|
||||
}
|
||||
@@ -183,7 +186,7 @@ func (i *Imp) Start() error {
|
||||
zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes),
|
||||
)
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
invokeURI := ""
|
||||
if cfg.GRPC != nil {
|
||||
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
||||
@@ -194,6 +197,7 @@ func (i *Imp) Start() error {
|
||||
mntxservice.WithMonetixConfig(monetixCfg),
|
||||
mntxservice.WithGatewayDescriptor(gatewayDescriptor),
|
||||
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
|
||||
mntxservice.WithStorage(repo),
|
||||
)
|
||||
i.service = svc
|
||||
|
||||
@@ -204,7 +208,11 @@ func (i *Imp) Start() error {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, nil, serviceFactory)
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
return gatewaymongo.New(logger, conn)
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -92,10 +92,10 @@ func mapCallbackToState(clock clockpkg.Clock, cfg monetix.Config, cb monetixCall
|
||||
internalStatus := mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||
|
||||
if status == cfg.SuccessStatus() && opStatus == cfg.SuccessStatus() && (code == "" || code == "0") {
|
||||
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
|
||||
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
|
||||
outcome = monetix.OutcomeSuccess
|
||||
} else if status == cfg.ProcessingStatus() || opStatus == cfg.ProcessingStatus() {
|
||||
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
|
||||
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
|
||||
outcome = monetix.OutcomeProcessing
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestMapCallbackToState_StatusMapping(t *testing.T) {
|
||||
paymentStatus: "success",
|
||||
operationStatus: "success",
|
||||
code: "0",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED,
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS,
|
||||
expectedOutcome: monetix.OutcomeSuccess,
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@ func TestMapCallbackToState_StatusMapping(t *testing.T) {
|
||||
paymentStatus: "processing",
|
||||
operationStatus: "success",
|
||||
code: "",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING,
|
||||
expectedOutcome: monetix.OutcomeProcessing,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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,67 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage"
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
)
|
||||
|
||||
// mockRepository implements storage.Repository for tests.
|
||||
type mockRepository struct {
|
||||
payouts *cardPayoutStore
|
||||
}
|
||||
|
||||
func newMockRepository() *mockRepository {
|
||||
return &mockRepository{
|
||||
payouts: newCardPayoutStore(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *mockRepository) Payouts() storage.PayoutsStore {
|
||||
return r.payouts
|
||||
}
|
||||
|
||||
// cardPayoutStore implements storage.PayoutsStore for tests.
|
||||
type cardPayoutStore struct {
|
||||
data map[string]*model.CardPayout
|
||||
}
|
||||
|
||||
func newCardPayoutStore() *cardPayoutStore {
|
||||
return &cardPayoutStore{
|
||||
data: make(map[string]*model.CardPayout),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (*model.CardPayout, error) {
|
||||
for _, v := range s.data {
|
||||
if v.IdempotencyKey == key {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) {
|
||||
v, ok := s.data[id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *cardPayoutStore) Upsert(_ context.Context, record *model.CardPayout) error {
|
||||
s.data[record.PayoutID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save is a helper for tests to pre-populate data.
|
||||
func (s *cardPayoutStore) Save(state *model.CardPayout) {
|
||||
s.data[state.PayoutID] = state
|
||||
}
|
||||
|
||||
// Get is a helper for tests to retrieve data.
|
||||
func (s *cardPayoutStore) Get(id string) (*model.CardPayout, bool) {
|
||||
v, ok := s.data[id]
|
||||
return v, ok
|
||||
}
|
||||
@@ -8,30 +8,33 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/gateway/mntx/storage"
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
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
|
||||
store storage.Repository
|
||||
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 {
|
||||
func newCardPayoutProcessor(
|
||||
logger mlogger.Logger,
|
||||
cfg monetix.Config,
|
||||
clock clockpkg.Clock,
|
||||
store storage.Repository,
|
||||
client *http.Client,
|
||||
producer msg.Producer,
|
||||
) *cardPayoutProcessor {
|
||||
return &cardPayoutProcessor{
|
||||
logger: logger.Named("card_payout_processor"),
|
||||
config: cfg,
|
||||
@@ -46,18 +49,23 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
if p == nil {
|
||||
return nil, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
|
||||
req = sanitizeCardPayoutRequest(req)
|
||||
|
||||
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()))),
|
||||
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
)
|
||||
|
||||
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()),
|
||||
@@ -76,53 +84,88 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
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,
|
||||
now := p.clock.Now()
|
||||
|
||||
state := &model.CardPayout{
|
||||
PayoutID: strings.TrimSpace(req.GetPayoutId()),
|
||||
OperationRef: strings.TrimSpace(req.GetOperationRef()),
|
||||
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||
ProjectID: projectID,
|
||||
CustomerID: strings.TrimSpace(req.GetCustomerId()),
|
||||
AmountMinor: req.GetAmountMinor(),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
|
||||
Status: model.PayoutStatusWaiting,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
|
||||
if existing.GetCreatedAt() != nil {
|
||||
state.CreatedAt = existing.GetCreatedAt()
|
||||
// Keep CreatedAt/refs if record already exists.
|
||||
if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err == nil && existing != nil {
|
||||
if !existing.CreatedAt.IsZero() {
|
||||
state.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if state.OperationRef == "" {
|
||||
state.OperationRef = existing.OperationRef
|
||||
}
|
||||
if state.IdempotencyKey == "" {
|
||||
state.IdempotencyKey = existing.IdempotencyKey
|
||||
}
|
||||
}
|
||||
|
||||
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.Status = model.PayoutStatusFailed
|
||||
state.ProviderMessage = err.Error()
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
p.store.Save(state)
|
||||
state.UpdatedAt = p.clock.Now()
|
||||
|
||||
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
||||
p.logger.Warn("Failed to update payout status",
|
||||
zap.Error(e),
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
zap.String("customer_id", state.CustomerID),
|
||||
zap.String("operation_ref", state.OperationRef),
|
||||
zap.String("idempotency_key", state.IdempotencyKey),
|
||||
)
|
||||
}
|
||||
|
||||
p.logger.Warn("Monetix payout submission failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
zap.String("customer_id", state.CustomerID),
|
||||
zap.String("operation_ref", state.OperationRef),
|
||||
zap.String("idempotency_key", state.IdempotencyKey),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state.ProviderPaymentId = result.ProviderRequestID
|
||||
// Provider request id is the provider-side payment id in your model.
|
||||
state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID)
|
||||
if result.Accepted {
|
||||
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
|
||||
state.Status = model.PayoutStatusWaiting
|
||||
} else {
|
||||
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||
state.ProviderCode = result.ErrorCode
|
||||
state.ProviderMessage = result.ErrorMessage
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
|
||||
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
|
||||
}
|
||||
|
||||
state.UpdatedAt = p.clock.Now()
|
||||
if err := p.updatePayoutStatus(ctx, state); err != nil {
|
||||
p.logger.Warn("Failed to store payout",
|
||||
zap.Error(err),
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
zap.String("customer_id", state.CustomerID),
|
||||
zap.String("operation_ref", state.OperationRef),
|
||||
zap.String("idempotency_key", state.IdempotencyKey),
|
||||
)
|
||||
// do not fail request here: provider already answered and client expects response
|
||||
}
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
p.store.Save(state)
|
||||
|
||||
resp := &mntxv1.CardPayoutResponse{
|
||||
Payout: state,
|
||||
Payout: StateToProto(state),
|
||||
Accepted: result.Accepted,
|
||||
ProviderRequestId: result.ProviderRequestID,
|
||||
ErrorCode: result.ErrorCode,
|
||||
@@ -130,8 +173,8 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
}
|
||||
|
||||
p.logger.Info("Card payout submission stored",
|
||||
zap.String("payout_id", state.GetPayoutId()),
|
||||
zap.String("status", state.GetStatus().String()),
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
zap.String("status", string(state.Status)),
|
||||
zap.Bool("accepted", result.Accepted),
|
||||
zap.String("provider_request_id", result.ProviderRequestID),
|
||||
)
|
||||
@@ -143,18 +186,23 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
if p == nil {
|
||||
return nil, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
|
||||
req = sanitizeCardTokenPayoutRequest(req)
|
||||
|
||||
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()))),
|
||||
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
)
|
||||
|
||||
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()),
|
||||
@@ -173,53 +221,69 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
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,
|
||||
now := p.clock.Now()
|
||||
state := &model.CardPayout{
|
||||
PayoutID: strings.TrimSpace(req.GetPayoutId()),
|
||||
OperationRef: strings.TrimSpace(req.GetOperationRef()),
|
||||
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||
ProjectID: projectID,
|
||||
CustomerID: strings.TrimSpace(req.GetCustomerId()),
|
||||
AmountMinor: req.GetAmountMinor(),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
|
||||
Status: model.PayoutStatusWaiting,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
|
||||
if existing.GetCreatedAt() != nil {
|
||||
state.CreatedAt = existing.GetCreatedAt()
|
||||
if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err == nil && existing != nil {
|
||||
if !existing.CreatedAt.IsZero() {
|
||||
state.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if state.OperationRef == "" {
|
||||
state.OperationRef = existing.OperationRef
|
||||
}
|
||||
if state.IdempotencyKey == "" {
|
||||
state.IdempotencyKey = existing.IdempotencyKey
|
||||
}
|
||||
}
|
||||
|
||||
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.Status = model.PayoutStatusFailed
|
||||
state.ProviderMessage = err.Error()
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
p.store.Save(state)
|
||||
state.UpdatedAt = p.clock.Now()
|
||||
|
||||
_ = p.updatePayoutStatus(ctx, state)
|
||||
|
||||
p.logger.Warn("Monetix token payout submission failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
zap.String("customer_id", state.CustomerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state.ProviderPaymentId = result.ProviderRequestID
|
||||
state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID)
|
||||
if result.Accepted {
|
||||
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
|
||||
state.Status = model.PayoutStatusWaiting
|
||||
} else {
|
||||
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||
state.ProviderCode = result.ErrorCode
|
||||
state.ProviderMessage = result.ErrorMessage
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
|
||||
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
|
||||
}
|
||||
|
||||
state.UpdatedAt = p.clock.Now()
|
||||
if err := p.updatePayoutStatus(ctx, state); err != nil {
|
||||
p.logger.Warn("Failed to update payout status", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
p.store.Save(state)
|
||||
|
||||
resp := &mntxv1.CardTokenPayoutResponse{
|
||||
Payout: state,
|
||||
Payout: StateToProto(state),
|
||||
Accepted: result.Accepted,
|
||||
ProviderRequestId: result.ProviderRequestID,
|
||||
ErrorCode: result.ErrorCode,
|
||||
@@ -227,8 +291,8 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
}
|
||||
|
||||
p.logger.Info("Card token payout submission stored",
|
||||
zap.String("payout_id", state.GetPayoutId()),
|
||||
zap.String("status", state.GetStatus().String()),
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
zap.String("status", string(state.Status)),
|
||||
zap.Bool("accepted", result.Accepted),
|
||||
zap.String("provider_request_id", result.ProviderRequestID),
|
||||
)
|
||||
@@ -240,10 +304,12 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
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",
|
||||
@@ -265,8 +331,10 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
|
||||
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",
|
||||
@@ -298,36 +366,45 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv1.CardPayoutState, error) {
|
||||
func (p *cardPayoutProcessor) Status(ctx 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))
|
||||
state, err := p.store.Payouts().FindByPaymentID(ctx, id)
|
||||
if err != nil || state == nil {
|
||||
p.logger.Warn("Payout status not found", zap.String("payout_id", id), zap.Error(err))
|
||||
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
|
||||
|
||||
p.logger.Info("Card payout status resolved",
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
zap.String("status", string(state.Status)),
|
||||
)
|
||||
|
||||
return StateToProto(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")
|
||||
@@ -354,45 +431,48 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
return status, 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()
|
||||
// mapCallbackToState currently returns proto-state in your code.
|
||||
// Convert it to mongo model and preserve internal refs if record exists.
|
||||
pbState, statusLabel := mapCallbackToState(p.clock, p.config, cb)
|
||||
|
||||
// Convert proto -> mongo (operationRef/idempotencyKey are internal; keep empty for now)
|
||||
state := CardPayoutStateFromProto(p.clock, pbState)
|
||||
|
||||
// Preserve CreatedAt + internal keys from existing record if present.
|
||||
if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err != nil {
|
||||
p.logger.Warn("Failed to fetch payout state while processing callback",
|
||||
zap.Error(err),
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
)
|
||||
return http.StatusInternalServerError, err
|
||||
} else if existing != nil {
|
||||
if !existing.CreatedAt.IsZero() {
|
||||
state.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if state.OperationRef == "" {
|
||||
state.OperationRef = existing.OperationRef
|
||||
}
|
||||
if state.IdempotencyKey == "" {
|
||||
state.IdempotencyKey = existing.IdempotencyKey
|
||||
}
|
||||
// keep failure reason if you want, or override depending on callback semantics
|
||||
if state.FailureReason == "" {
|
||||
state.FailureReason = existing.FailureReason
|
||||
}
|
||||
}
|
||||
p.store.Save(state)
|
||||
p.emitCardPayoutEvent(state)
|
||||
|
||||
if err := p.updatePayoutStatus(ctx, state); err != nil {
|
||||
p.logger.Warn("Failed to update payout state while processing callback", zap.Error(err))
|
||||
}
|
||||
monetix.ObserveCallback(statusLabel)
|
||||
|
||||
p.logger.Info("Monetix payout callback processed",
|
||||
zap.String("payout_id", state.GetPayoutId()),
|
||||
zap.String("payout_id", state.PayoutID),
|
||||
zap.String("status", statusLabel),
|
||||
zap.String("provider_code", state.GetProviderCode()),
|
||||
zap.String("provider_message", state.GetProviderMessage()),
|
||||
zap.String("provider_code", state.ProviderCode),
|
||||
zap.String("provider_message", state.ProviderMessage),
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -40,10 +40,11 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
existingCreated := timestamppb.New(time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC))
|
||||
store := newCardPayoutStore()
|
||||
store.Save(&mntxv1.CardPayoutState{
|
||||
PayoutId: "payout-1",
|
||||
existingCreated := time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
repo := newMockRepository()
|
||||
repo.payouts.Save(&model.CardPayout{
|
||||
PayoutID: "payout-1",
|
||||
CreatedAt: existingCreated,
|
||||
})
|
||||
|
||||
@@ -61,7 +62,7 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, store, httpClient, nil)
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, httpClient, nil)
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
req.ProjectId = 0
|
||||
@@ -76,27 +77,38 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
||||
if resp.GetPayout().GetProjectId() != cfg.ProjectID {
|
||||
t.Fatalf("expected project id %d, got %d", cfg.ProjectID, resp.GetPayout().GetProjectId())
|
||||
}
|
||||
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING {
|
||||
t.Fatalf("expected pending status, got %v", resp.GetPayout().GetStatus())
|
||||
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING {
|
||||
t.Fatalf("expected waiting status, got %v", resp.GetPayout().GetStatus())
|
||||
}
|
||||
if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated.AsTime()) {
|
||||
if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated) {
|
||||
t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime())
|
||||
}
|
||||
|
||||
stored, ok := store.Get(req.GetPayoutId())
|
||||
stored, ok := repo.payouts.Get(req.GetPayoutId())
|
||||
if !ok || stored == nil {
|
||||
t.Fatalf("expected payout state stored")
|
||||
}
|
||||
if stored.GetProviderPaymentId() == "" {
|
||||
if stored.ProviderPaymentID == "" {
|
||||
t.Fatalf("expected provider payment id")
|
||||
}
|
||||
if !stored.CreatedAt.Equal(existingCreated) {
|
||||
t.Fatalf("expected created_at preserved in model, got %v", stored.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, clockpkg.NewSystem(), newCardPayoutStore(), &http.Client{}, nil)
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
clockpkg.NewSystem(),
|
||||
newMockRepository(),
|
||||
&http.Client{},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||
if err == nil {
|
||||
@@ -114,12 +126,21 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
|
||||
StatusProcessing: "processing",
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
store := newCardPayoutStore()
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}, store, &http.Client{}, nil)
|
||||
|
||||
repo := newMockRepository()
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)},
|
||||
repo,
|
||||
&http.Client{},
|
||||
nil,
|
||||
)
|
||||
|
||||
cb := baseCallback()
|
||||
cb.Payment.Sum.Currency = "RUB"
|
||||
cb.Signature = ""
|
||||
|
||||
sig, err := monetix.SignPayload(cb, cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign callback: %v", err)
|
||||
@@ -139,11 +160,12 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
|
||||
t.Fatalf("expected status ok, got %d", status)
|
||||
}
|
||||
|
||||
state, ok := store.Get(cb.Payment.ID)
|
||||
state, ok := repo.payouts.Get(cb.Payment.ID)
|
||||
if !ok || state == nil {
|
||||
t.Fatalf("expected payout state stored")
|
||||
}
|
||||
if state.GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED {
|
||||
t.Fatalf("expected processed status, got %v", state.GetStatus())
|
||||
|
||||
if state.Status != model.PayoutStatusSuccess {
|
||||
t.Fatalf("expected success status in model, got %v", state.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||
"github.com/tech/sendico/pkg/connector/params"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
@@ -19,9 +19,9 @@ const mntxConnectorID = "mntx"
|
||||
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
|
||||
return &connectorv1.GetCapabilitiesResponse{
|
||||
Capabilities: &connectorv1.ConnectorCapabilities{
|
||||
ConnectorType: mntxConnectorID,
|
||||
Version: appversion.Create().Short(),
|
||||
SupportedAccountKinds: nil,
|
||||
ConnectorType: mntxConnectorID,
|
||||
Version: appversion.Create().Short(),
|
||||
SupportedAccountKinds: nil,
|
||||
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT},
|
||||
OperationParams: mntxOperationParams(),
|
||||
},
|
||||
@@ -161,49 +161,49 @@ func currencyFromOperation(op *connectorv1.Operation) string {
|
||||
|
||||
func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest {
|
||||
req := &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
ProjectId: readerInt64(reader, "project_id"),
|
||||
CustomerId: strings.TrimSpace(reader.String("customer_id")),
|
||||
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
|
||||
PayoutId: payoutID,
|
||||
ProjectId: readerInt64(reader, "project_id"),
|
||||
CustomerId: strings.TrimSpace(reader.String("customer_id")),
|
||||
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
|
||||
CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")),
|
||||
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
|
||||
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
|
||||
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
|
||||
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
|
||||
CustomerState: strings.TrimSpace(reader.String("customer_state")),
|
||||
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
|
||||
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
|
||||
AmountMinor: amountMinor,
|
||||
Currency: currency,
|
||||
CardToken: strings.TrimSpace(reader.String("card_token")),
|
||||
CardHolder: strings.TrimSpace(reader.String("card_holder")),
|
||||
MaskedPan: strings.TrimSpace(reader.String("masked_pan")),
|
||||
Metadata: reader.StringMap("metadata"),
|
||||
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
|
||||
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
|
||||
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
|
||||
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
|
||||
CustomerState: strings.TrimSpace(reader.String("customer_state")),
|
||||
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
|
||||
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
|
||||
AmountMinor: amountMinor,
|
||||
Currency: currency,
|
||||
CardToken: strings.TrimSpace(reader.String("card_token")),
|
||||
CardHolder: strings.TrimSpace(reader.String("card_holder")),
|
||||
MaskedPan: strings.TrimSpace(reader.String("masked_pan")),
|
||||
Metadata: reader.StringMap("metadata"),
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func buildCardPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest {
|
||||
return &mntxv1.CardPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
ProjectId: readerInt64(reader, "project_id"),
|
||||
CustomerId: strings.TrimSpace(reader.String("customer_id")),
|
||||
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
|
||||
PayoutId: payoutID,
|
||||
ProjectId: readerInt64(reader, "project_id"),
|
||||
CustomerId: strings.TrimSpace(reader.String("customer_id")),
|
||||
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
|
||||
CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")),
|
||||
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
|
||||
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
|
||||
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
|
||||
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
|
||||
CustomerState: strings.TrimSpace(reader.String("customer_state")),
|
||||
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
|
||||
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
|
||||
AmountMinor: amountMinor,
|
||||
Currency: currency,
|
||||
CardPan: strings.TrimSpace(reader.String("card_pan")),
|
||||
CardExpYear: uint32(readerInt64(reader, "card_exp_year")),
|
||||
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")),
|
||||
CardHolder: strings.TrimSpace(reader.String("card_holder")),
|
||||
Metadata: reader.StringMap("metadata"),
|
||||
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
|
||||
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
|
||||
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
|
||||
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
|
||||
CustomerState: strings.TrimSpace(reader.String("customer_state")),
|
||||
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
|
||||
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
|
||||
AmountMinor: amountMinor,
|
||||
Currency: currency,
|
||||
CardPan: strings.TrimSpace(reader.String("card_pan")),
|
||||
CardExpYear: uint32(readerInt64(reader, "card_exp_year")),
|
||||
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")),
|
||||
CardHolder: strings.TrimSpace(reader.String("card_holder")),
|
||||
Metadata: reader.StringMap("metadata"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ func readerInt64(reader params.Reader, key string) int64 {
|
||||
func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt {
|
||||
if state == nil {
|
||||
return &connectorv1.OperationReceipt{
|
||||
Status: connectorv1.OperationStatus_PENDING,
|
||||
Status: connectorv1.OperationStatus_OPERATION_PROCESSING,
|
||||
}
|
||||
}
|
||||
return &connectorv1.OperationReceipt{
|
||||
@@ -252,14 +252,24 @@ func minorToDecimal(amount int64) string {
|
||||
|
||||
func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus {
|
||||
switch status {
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
|
||||
return connectorv1.OperationStatus_CONFIRMED
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
|
||||
return connectorv1.OperationStatus_OPERATION_CREATED
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
|
||||
return connectorv1.OperationStatus_OPERATION_WAITING
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
|
||||
return connectorv1.OperationStatus_OPERATION_SUCCESS
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||
return connectorv1.OperationStatus_FAILED
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
|
||||
return connectorv1.OperationStatus_PENDING
|
||||
return connectorv1.OperationStatus_OPERATION_FAILED
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
|
||||
return connectorv1.OperationStatus_OPERATION_CANCELLED
|
||||
|
||||
default:
|
||||
return connectorv1.OperationStatus_PENDING
|
||||
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
97
api/gateway/mntx/internal/service/gateway/helpers.go
Normal file
97
api/gateway/mntx/internal/service/gateway/helpers.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func tsOrNow(clock clockpkg.Clock, ts *timestamppb.Timestamp) time.Time {
|
||||
if ts == nil {
|
||||
return clock.Now()
|
||||
}
|
||||
return ts.AsTime()
|
||||
}
|
||||
|
||||
func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) *model.CardPayout {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &model.CardPayout{
|
||||
PayoutID: p.PayoutId,
|
||||
OperationRef: p.GetOperationRef(),
|
||||
IntentRef: p.GetIntentRef(),
|
||||
IdempotencyKey: p.GetIdempotencyKey(),
|
||||
ProjectID: p.ProjectId,
|
||||
CustomerID: p.CustomerId,
|
||||
AmountMinor: p.AmountMinor,
|
||||
Currency: p.Currency,
|
||||
Status: payoutStatusFromProto(p.Status),
|
||||
ProviderCode: p.ProviderCode,
|
||||
ProviderMessage: p.ProviderMessage,
|
||||
ProviderPaymentID: p.ProviderPaymentId,
|
||||
CreatedAt: tsOrNow(clock, p.CreatedAt),
|
||||
UpdatedAt: tsOrNow(clock, p.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState {
|
||||
return &mntxv1.CardPayoutState{
|
||||
PayoutId: m.PayoutID,
|
||||
ProjectId: m.ProjectID,
|
||||
CustomerId: m.CustomerID,
|
||||
AmountMinor: m.AmountMinor,
|
||||
Currency: m.Currency,
|
||||
Status: payoutStatusToProto(m.Status),
|
||||
ProviderCode: m.ProviderCode,
|
||||
ProviderMessage: m.ProviderMessage,
|
||||
ProviderPaymentId: m.ProviderPaymentID,
|
||||
CreatedAt: timestamppb.New(m.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(m.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus {
|
||||
switch s {
|
||||
case model.PayoutStatusCreated:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED
|
||||
case model.PayoutStatusProcessing:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
|
||||
case model.PayoutStatusWaiting:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
|
||||
case model.PayoutStatusSuccess:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
|
||||
case model.PayoutStatusFailed:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||
case model.PayoutStatusCancelled:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
|
||||
default:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED
|
||||
}
|
||||
}
|
||||
|
||||
func payoutStatusFromProto(s mntxv1.PayoutStatus) model.PayoutStatus {
|
||||
switch s {
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
|
||||
return model.PayoutStatusCreated
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
|
||||
return model.PayoutStatusWaiting
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
|
||||
return model.PayoutStatusSuccess
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||
return model.PayoutStatusFailed
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
|
||||
return model.PayoutStatusCancelled
|
||||
|
||||
default:
|
||||
return model.PayoutStatusCreated
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/gateway/mntx/storage"
|
||||
"github.com/tech/sendico/pkg/clock"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
@@ -29,6 +30,12 @@ func WithProducer(p msg.Producer) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithStorage(storage storage.Repository) Option {
|
||||
return func(s *Service) {
|
||||
s.storage = storage
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient injects a custom HTTP client (useful for tests).
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(s *Service) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/gateway/mntx/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
@@ -24,7 +25,7 @@ type Service struct {
|
||||
logger mlogger.Logger
|
||||
clock clockpkg.Clock
|
||||
producer msg.Producer
|
||||
cardStore *cardPayoutStore
|
||||
storage storage.Repository
|
||||
config monetix.Config
|
||||
httpClient *http.Client
|
||||
card *cardPayoutProcessor
|
||||
@@ -60,10 +61,9 @@ func (r reasonedError) Reason() string {
|
||||
// NewService constructs the Monetix gateway service skeleton.
|
||||
func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
||||
svc := &Service{
|
||||
logger: logger.Named("service"),
|
||||
clock: clockpkg.NewSystem(),
|
||||
cardStore: newCardPayoutStore(),
|
||||
config: monetix.DefaultConfig(),
|
||||
logger: logger.Named("service"),
|
||||
clock: clockpkg.NewSystem(),
|
||||
config: monetix.DefaultConfig(),
|
||||
}
|
||||
|
||||
initMetrics()
|
||||
@@ -84,11 +84,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
||||
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)
|
||||
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer)
|
||||
svc.startDiscoveryAnnouncer()
|
||||
|
||||
return svc
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
paytypes "github.com/tech/sendico/pkg/payments/types"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func isFinalStatus(t *model.CardPayout) bool {
|
||||
switch t.Status {
|
||||
case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func toOpStatus(t *model.CardPayout) rail.OperationResult {
|
||||
switch t.Status {
|
||||
case model.PayoutStatusFailed:
|
||||
return rail.OperationResultFailed
|
||||
case model.PayoutStatusSuccess:
|
||||
return rail.OperationResultSuccess
|
||||
case model.PayoutStatusCancelled:
|
||||
return rail.OperationResultCancelled
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected transfer status, %s", t.Status))
|
||||
}
|
||||
}
|
||||
|
||||
func toError(t *model.CardPayout) *gatewayv1.OperationError {
|
||||
if t.Status == model.PayoutStatusSuccess {
|
||||
return nil
|
||||
}
|
||||
return &gatewayv1.OperationError{
|
||||
Message: t.FailureReason,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *model.CardPayout) error {
|
||||
if err := p.store.Payouts().Upsert(ctx, state); err != nil {
|
||||
p.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", state.PayoutID), zap.String("status", string(state.Status)), zap.Error(err))
|
||||
}
|
||||
if isFinalStatus(state) {
|
||||
p.emitTransferStatusEvent(state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *cardPayoutProcessor) emitTransferStatusEvent(payout *model.CardPayout) {
|
||||
if p == nil || p.producer == nil || payout == nil {
|
||||
return
|
||||
}
|
||||
|
||||
exec := pmodel.PaymentGatewayExecution{
|
||||
PaymentIntentID: payout.IntentRef,
|
||||
IdempotencyKey: payout.IdempotencyKey,
|
||||
ExecutedMoney: &paytypes.Money{
|
||||
Amount: fmt.Sprintf("%d", payout.AmountMinor),
|
||||
Currency: payout.Currency,
|
||||
},
|
||||
PaymentRef: payout.PaymentRef,
|
||||
Status: toOpStatus(payout),
|
||||
OperationRef: payout.OperationRef,
|
||||
Error: payout.FailureReason,
|
||||
TransferRef: payout.GetID().Hex(),
|
||||
}
|
||||
env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec)
|
||||
if err := p.producer.SendMessage(env); err != nil {
|
||||
p.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", payout.ID))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user