mntx error codes update #684
@@ -54,7 +54,7 @@ type cardPayoutProcessor struct {
|
|||||||
dispatchSerialGate chan struct{}
|
dispatchSerialGate chan struct{}
|
||||||
|
|
||||||
retryPolicy payoutFailurePolicy
|
retryPolicy payoutFailurePolicy
|
||||||
retryDelayFn func(attempt uint32) time.Duration
|
retryDelayFn func(attempt uint32, strategy payoutRetryStrategy) time.Duration
|
||||||
|
|
||||||
retryMu sync.Mutex
|
retryMu sync.Mutex
|
||||||
retryTimers map[string]*time.Timer
|
retryTimers map[string]*time.Timer
|
||||||
@@ -149,15 +149,13 @@ func applyCardPayoutSendResult(state *model.CardPayout, result *monetix.CardPayo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID)
|
state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID)
|
||||||
|
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
|
||||||
|
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
|
||||||
if result.Accepted {
|
if result.Accepted {
|
||||||
state.Status = model.PayoutStatusWaiting
|
state.Status = model.PayoutStatusWaiting
|
||||||
state.ProviderCode = ""
|
|
||||||
state.ProviderMessage = ""
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = model.PayoutStatusFailed
|
||||||
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
|
|
||||||
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func payoutStateLogFields(state *model.CardPayout) []zap.Field {
|
func payoutStateLogFields(state *model.CardPayout) []zap.Field {
|
||||||
@@ -593,13 +591,20 @@ func payoutAcceptedForState(state *model.CardPayout) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch state.Status {
|
switch state.Status {
|
||||||
case model.PayoutStatusFailed, model.PayoutStatusCancelled:
|
case model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func terminalStatusAfterRetryExhausted(decision payoutFailureDecision) model.PayoutStatus {
|
||||||
|
if decision.Action == payoutFailureActionRetry {
|
||||||
|
return model.PayoutStatusNeedsAttention
|
||||||
|
}
|
||||||
|
return model.PayoutStatusFailed
|
||||||
|
}
|
||||||
|
|
||||||
func cardPayoutResponseFromState(
|
func cardPayoutResponseFromState(
|
||||||
state *model.CardPayout,
|
state *model.CardPayout,
|
||||||
accepted bool,
|
accepted bool,
|
||||||
@@ -733,15 +738,21 @@ func (p *cardPayoutProcessor) scheduleRetryTimer(operationRef string, delay time
|
|||||||
p.retryTimers[key] = timer
|
p.retryTimers[key] = timer
|
||||||
}
|
}
|
||||||
|
|
||||||
func retryDelayDuration(attempt uint32) time.Duration {
|
func retryDelayDuration(attempt uint32, strategy payoutRetryStrategy) time.Duration {
|
||||||
return time.Duration(retryDelayForAttempt(attempt)) * time.Second
|
return time.Duration(retryDelayForAttempt(attempt, strategy)) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequest, failedAttempt uint32, maxAttempts uint32) {
|
func (p *cardPayoutProcessor) scheduleCardPayoutRetry(
|
||||||
|
req *mntxv1.CardPayoutRequest,
|
||||||
|
failedAttempt uint32,
|
||||||
|
maxAttempts uint32,
|
||||||
|
strategy payoutRetryStrategy,
|
||||||
|
) {
|
||||||
if p == nil || req == nil {
|
if p == nil || req == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
maxAttempts = maxDispatchAttempts(maxAttempts)
|
maxAttempts = maxDispatchAttempts(maxAttempts)
|
||||||
|
strategy = normalizeRetryStrategy(strategy)
|
||||||
nextAttempt := failedAttempt + 1
|
nextAttempt := failedAttempt + 1
|
||||||
if nextAttempt > maxAttempts {
|
if nextAttempt > maxAttempts {
|
||||||
return
|
return
|
||||||
@@ -751,12 +762,13 @@ func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId())
|
operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId())
|
||||||
delay := retryDelayDuration(failedAttempt)
|
delay := retryDelayDuration(failedAttempt, strategy)
|
||||||
if p.retryDelayFn != nil {
|
if p.retryDelayFn != nil {
|
||||||
delay = p.retryDelayFn(failedAttempt)
|
delay = p.retryDelayFn(failedAttempt, strategy)
|
||||||
}
|
}
|
||||||
p.logger.Info("Scheduling card payout retry",
|
p.logger.Info("Scheduling card payout retry",
|
||||||
zap.String("operation_ref", operationRef),
|
zap.String("operation_ref", operationRef),
|
||||||
|
zap.String("strategy", strategy.String()),
|
||||||
zap.Uint32("failed_attempt", failedAttempt),
|
zap.Uint32("failed_attempt", failedAttempt),
|
||||||
zap.Uint32("next_attempt", nextAttempt),
|
zap.Uint32("next_attempt", nextAttempt),
|
||||||
zap.Uint32("max_attempts", maxAttempts),
|
zap.Uint32("max_attempts", maxAttempts),
|
||||||
@@ -767,11 +779,17 @@ func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequ
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(req *mntxv1.CardTokenPayoutRequest, failedAttempt uint32, maxAttempts uint32) {
|
func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(
|
||||||
|
req *mntxv1.CardTokenPayoutRequest,
|
||||||
|
failedAttempt uint32,
|
||||||
|
maxAttempts uint32,
|
||||||
|
strategy payoutRetryStrategy,
|
||||||
|
) {
|
||||||
if p == nil || req == nil {
|
if p == nil || req == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
maxAttempts = maxDispatchAttempts(maxAttempts)
|
maxAttempts = maxDispatchAttempts(maxAttempts)
|
||||||
|
strategy = normalizeRetryStrategy(strategy)
|
||||||
nextAttempt := failedAttempt + 1
|
nextAttempt := failedAttempt + 1
|
||||||
if nextAttempt > maxAttempts {
|
if nextAttempt > maxAttempts {
|
||||||
return
|
return
|
||||||
@@ -781,12 +799,13 @@ func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(req *mntxv1.CardToken
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId())
|
operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId())
|
||||||
delay := retryDelayDuration(failedAttempt)
|
delay := retryDelayDuration(failedAttempt, strategy)
|
||||||
if p.retryDelayFn != nil {
|
if p.retryDelayFn != nil {
|
||||||
delay = p.retryDelayFn(failedAttempt)
|
delay = p.retryDelayFn(failedAttempt, strategy)
|
||||||
}
|
}
|
||||||
p.logger.Info("Scheduling card token payout retry",
|
p.logger.Info("Scheduling card token payout retry",
|
||||||
zap.String("operation_ref", operationRef),
|
zap.String("operation_ref", operationRef),
|
||||||
|
zap.String("strategy", strategy.String()),
|
||||||
zap.Uint32("failed_attempt", failedAttempt),
|
zap.Uint32("failed_attempt", failedAttempt),
|
||||||
zap.Uint32("next_attempt", nextAttempt),
|
zap.Uint32("next_attempt", nextAttempt),
|
||||||
zap.Uint32("max_attempts", maxAttempts),
|
zap.Uint32("max_attempts", maxAttempts),
|
||||||
@@ -857,11 +876,11 @@ func (p *cardPayoutProcessor) runCardPayoutRetry(req *mntxv1.CardPayoutRequest,
|
|||||||
p.logger.Warn("Failed to persist retryable payout transport failure", zap.Error(upErr))
|
p.logger.Warn("Failed to persist retryable payout transport failure", zap.Error(upErr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.scheduleCardPayoutRetry(req, attempt, maxAttempts)
|
p.scheduleCardPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||||
state.FailureReason = payoutFailureReason("", err.Error())
|
state.FailureReason = payoutFailureReason("", err.Error())
|
||||||
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
||||||
p.logger.Warn("Failed to persist terminal payout transport failure", zap.Error(upErr))
|
p.logger.Warn("Failed to persist terminal payout transport failure", zap.Error(upErr))
|
||||||
@@ -889,11 +908,11 @@ func (p *cardPayoutProcessor) runCardPayoutRetry(req *mntxv1.CardPayoutRequest,
|
|||||||
p.logger.Warn("Failed to persist retryable payout provider failure", zap.Error(upErr))
|
p.logger.Warn("Failed to persist retryable payout provider failure", zap.Error(upErr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.scheduleCardPayoutRetry(req, attempt, maxAttempts)
|
p.scheduleCardPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||||
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
||||||
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
||||||
p.logger.Warn("Failed to persist terminal payout provider failure", zap.Error(upErr))
|
p.logger.Warn("Failed to persist terminal payout provider failure", zap.Error(upErr))
|
||||||
@@ -946,11 +965,11 @@ func (p *cardPayoutProcessor) runCardTokenPayoutRetry(req *mntxv1.CardTokenPayou
|
|||||||
p.logger.Warn("Failed to persist retryable token payout transport failure", zap.Error(upErr))
|
p.logger.Warn("Failed to persist retryable token payout transport failure", zap.Error(upErr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts)
|
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||||
state.FailureReason = payoutFailureReason("", err.Error())
|
state.FailureReason = payoutFailureReason("", err.Error())
|
||||||
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
||||||
p.logger.Warn("Failed to persist terminal token payout transport failure", zap.Error(upErr))
|
p.logger.Warn("Failed to persist terminal token payout transport failure", zap.Error(upErr))
|
||||||
@@ -978,11 +997,11 @@ func (p *cardPayoutProcessor) runCardTokenPayoutRetry(req *mntxv1.CardTokenPayou
|
|||||||
p.logger.Warn("Failed to persist retryable token payout provider failure", zap.Error(upErr))
|
p.logger.Warn("Failed to persist retryable token payout provider failure", zap.Error(upErr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts)
|
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||||
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
||||||
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
||||||
p.logger.Warn("Failed to persist terminal token payout provider failure", zap.Error(upErr))
|
p.logger.Warn("Failed to persist terminal token payout provider failure", zap.Error(upErr))
|
||||||
@@ -1067,7 +1086,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
switch existing.Status {
|
switch existing.Status {
|
||||||
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusCancelled:
|
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
|
||||||
p.observeExecutionState(existing)
|
p.observeExecutionState(existing)
|
||||||
return cardPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil
|
return cardPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil
|
||||||
}
|
}
|
||||||
@@ -1088,11 +1107,11 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
p.logger.Warn("Failed to update payout status", fields...)
|
p.logger.Warn("Failed to update payout status", fields...)
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
p.scheduleCardPayoutRetry(req, 1, maxAttempts)
|
p.scheduleCardPayoutRetry(req, 1, maxAttempts, decision.Strategy)
|
||||||
return cardPayoutResponseFromState(state, true, "", ""), nil
|
return cardPayoutResponseFromState(state, true, "", ""), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||||
state.FailureReason = payoutFailureReason("", err.Error())
|
state.FailureReason = payoutFailureReason("", err.Error())
|
||||||
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
||||||
fields := append([]zap.Field{zap.Error(e)}, payoutStateLogFields(state)...)
|
fields := append([]zap.Field{zap.Error(e)}, payoutStateLogFields(state)...)
|
||||||
@@ -1112,6 +1131,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
errorMessage := strings.TrimSpace(result.ErrorMessage)
|
errorMessage := strings.TrimSpace(result.ErrorMessage)
|
||||||
scheduleRetry := false
|
scheduleRetry := false
|
||||||
retryMaxAttempts := uint32(0)
|
retryMaxAttempts := uint32(0)
|
||||||
|
retryStrategy := payoutRetryStrategyImmediate
|
||||||
|
|
||||||
if !result.Accepted {
|
if !result.Accepted {
|
||||||
decision := p.retryPolicy.decideProviderFailure(result.ErrorCode)
|
decision := p.retryPolicy.decideProviderFailure(result.ErrorCode)
|
||||||
@@ -1124,8 +1144,9 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
errorMessage = ""
|
errorMessage = ""
|
||||||
scheduleRetry = true
|
scheduleRetry = true
|
||||||
retryMaxAttempts = maxAttempts
|
retryMaxAttempts = maxAttempts
|
||||||
|
retryStrategy = decision.Strategy
|
||||||
} else {
|
} else {
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||||
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
||||||
p.clearRetryState(state.OperationRef)
|
p.clearRetryState(state.OperationRef)
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1165,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if scheduleRetry {
|
if scheduleRetry {
|
||||||
p.scheduleCardPayoutRetry(req, 1, retryMaxAttempts)
|
p.scheduleCardPayoutRetry(req, 1, retryMaxAttempts, retryStrategy)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := cardPayoutResponseFromState(state, accepted, errorCode, errorMessage)
|
resp := cardPayoutResponseFromState(state, accepted, errorCode, errorMessage)
|
||||||
@@ -1231,7 +1252,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
switch existing.Status {
|
switch existing.Status {
|
||||||
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusCancelled:
|
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
|
||||||
p.observeExecutionState(existing)
|
p.observeExecutionState(existing)
|
||||||
return cardTokenPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil
|
return cardTokenPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil
|
||||||
}
|
}
|
||||||
@@ -1250,11 +1271,11 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
p.scheduleCardTokenPayoutRetry(req, 1, maxAttempts)
|
p.scheduleCardTokenPayoutRetry(req, 1, maxAttempts, decision.Strategy)
|
||||||
return cardTokenPayoutResponseFromState(state, true, "", ""), nil
|
return cardTokenPayoutResponseFromState(state, true, "", ""), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||||
state.FailureReason = payoutFailureReason("", err.Error())
|
state.FailureReason = payoutFailureReason("", err.Error())
|
||||||
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
@@ -1274,6 +1295,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
errorMessage := strings.TrimSpace(result.ErrorMessage)
|
errorMessage := strings.TrimSpace(result.ErrorMessage)
|
||||||
scheduleRetry := false
|
scheduleRetry := false
|
||||||
retryMaxAttempts := uint32(0)
|
retryMaxAttempts := uint32(0)
|
||||||
|
retryStrategy := payoutRetryStrategyImmediate
|
||||||
|
|
||||||
if !result.Accepted {
|
if !result.Accepted {
|
||||||
decision := p.retryPolicy.decideProviderFailure(result.ErrorCode)
|
decision := p.retryPolicy.decideProviderFailure(result.ErrorCode)
|
||||||
@@ -1286,8 +1308,9 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
errorMessage = ""
|
errorMessage = ""
|
||||||
scheduleRetry = true
|
scheduleRetry = true
|
||||||
retryMaxAttempts = maxAttempts
|
retryMaxAttempts = maxAttempts
|
||||||
|
retryStrategy = decision.Strategy
|
||||||
} else {
|
} else {
|
||||||
state.Status = model.PayoutStatusFailed
|
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||||
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
||||||
p.clearRetryState(state.OperationRef)
|
p.clearRetryState(state.OperationRef)
|
||||||
}
|
}
|
||||||
@@ -1301,7 +1324,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if scheduleRetry {
|
if scheduleRetry {
|
||||||
p.scheduleCardTokenPayoutRetry(req, 1, retryMaxAttempts)
|
p.scheduleCardTokenPayoutRetry(req, 1, retryMaxAttempts, retryStrategy)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := cardTokenPayoutResponseFromState(state, accepted, errorCode, errorMessage)
|
resp := cardTokenPayoutResponseFromState(state, accepted, errorCode, errorMessage)
|
||||||
@@ -1470,7 +1493,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
|||||||
}
|
}
|
||||||
|
|
||||||
retryScheduled := false
|
retryScheduled := false
|
||||||
if state.Status == model.PayoutStatusFailed || state.Status == model.PayoutStatusCancelled {
|
if state.Status == model.PayoutStatusFailed || state.Status == model.PayoutStatusCancelled || state.Status == model.PayoutStatusNeedsAttention {
|
||||||
decision := p.retryPolicy.decideProviderFailure(state.ProviderCode)
|
decision := p.retryPolicy.decideProviderFailure(state.ProviderCode)
|
||||||
attemptsUsed := p.currentDispatchAttempt(operationRef)
|
attemptsUsed := p.currentDispatchAttempt(operationRef)
|
||||||
maxAttempts := p.maxDispatchAttempts()
|
maxAttempts := p.maxDispatchAttempts()
|
||||||
@@ -1488,7 +1511,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
|||||||
p.logger.Warn("Failed to persist callback retry scheduling state", zap.Error(err))
|
p.logger.Warn("Failed to persist callback retry scheduling state", zap.Error(err))
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
p.scheduleCardPayoutRetry(req, attemptsUsed, maxAttempts)
|
p.scheduleCardPayoutRetry(req, attemptsUsed, maxAttempts, decision.Strategy)
|
||||||
retryScheduled = true
|
retryScheduled = true
|
||||||
} else if req := p.loadCardTokenRetryRequest(operationRef); req != nil {
|
} else if req := p.loadCardTokenRetryRequest(operationRef); req != nil {
|
||||||
state.Status = model.PayoutStatusProcessing
|
state.Status = model.PayoutStatusProcessing
|
||||||
@@ -1503,7 +1526,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
|||||||
p.logger.Warn("Failed to persist callback token retry scheduling state", zap.Error(err))
|
p.logger.Warn("Failed to persist callback token retry scheduling state", zap.Error(err))
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
p.scheduleCardTokenPayoutRetry(req, attemptsUsed, maxAttempts)
|
p.scheduleCardTokenPayoutRetry(req, attemptsUsed, maxAttempts, decision.Strategy)
|
||||||
retryScheduled = true
|
retryScheduled = true
|
||||||
} else {
|
} else {
|
||||||
p.logger.Warn("Retryable callback decline received but no retry request snapshot found",
|
p.logger.Warn("Retryable callback decline received but no retry request snapshot found",
|
||||||
@@ -1514,6 +1537,12 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !retryScheduled && decision.Action == payoutFailureActionRetry {
|
||||||
|
state.Status = model.PayoutStatusNeedsAttention
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == model.PayoutStatusNeedsAttention {
|
||||||
|
state.Status = model.PayoutStatusNeedsAttention
|
||||||
|
}
|
||||||
if !retryScheduled && strings.TrimSpace(state.FailureReason) == "" {
|
if !retryScheduled && strings.TrimSpace(state.FailureReason) == "" {
|
||||||
state.FailureReason = payoutFailureReason(state.ProviderCode, state.ProviderMessage)
|
state.FailureReason = payoutFailureReason(state.ProviderCode, state.ProviderMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,68 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCardPayoutProcessor_Submit_AcceptedBodyErrorRemainsWaiting(t *testing.T) {
|
||||||
|
cfg := monetix.Config{
|
||||||
|
BaseURL: "https://monetix.test",
|
||||||
|
SecretKey: "secret",
|
||||||
|
ProjectID: 99,
|
||||||
|
AllowedCurrencies: []string{"RUB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := newMockRepository()
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
resp := monetix.APIResponse{
|
||||||
|
Status: "error",
|
||||||
|
Code: "3062",
|
||||||
|
Message: "Payment details not received",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resp)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, httpClient, nil)
|
||||||
|
|
||||||
|
req := validCardPayoutRequest()
|
||||||
|
|
||||||
|
resp, err := processor.Submit(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !resp.GetAccepted() {
|
||||||
|
t.Fatalf("expected accepted payout response")
|
||||||
|
}
|
||||||
|
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING {
|
||||||
|
t.Fatalf("expected waiting status, got %v", resp.GetPayout().GetStatus())
|
||||||
|
}
|
||||||
|
if got := resp.GetErrorCode(); got != "3062" {
|
||||||
|
t.Fatalf("expected response error code %q, got %q", "3062", got)
|
||||||
|
}
|
||||||
|
if got := resp.GetErrorMessage(); got != "Payment details not received" {
|
||||||
|
t.Fatalf("expected response error message, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, ok := repo.payouts.Get(req.GetPayoutId())
|
||||||
|
if !ok || stored == nil {
|
||||||
|
t.Fatalf("expected payout state stored")
|
||||||
|
}
|
||||||
|
if got := stored.Status; got != model.PayoutStatusWaiting {
|
||||||
|
t.Fatalf("expected stored waiting status, got %v", got)
|
||||||
|
}
|
||||||
|
if got := stored.ProviderCode; got != "3062" {
|
||||||
|
t.Fatalf("expected stored provider code %q, got %q", "3062", got)
|
||||||
|
}
|
||||||
|
if got := stored.ProviderMessage; got != "Payment details not received" {
|
||||||
|
t.Fatalf("expected stored provider message, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
|
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
|
||||||
cfg := monetix.Config{
|
cfg := monetix.Config{
|
||||||
AllowedCurrencies: []string{"RUB"},
|
AllowedCurrencies: []string{"RUB"},
|
||||||
@@ -525,7 +587,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
|
|||||||
n := calls.Add(1)
|
n := calls.Add(1)
|
||||||
resp := monetix.APIResponse{}
|
resp := monetix.APIResponse{}
|
||||||
if n == 1 {
|
if n == 1 {
|
||||||
resp.Code = providerCodeDeclineAmountOrFrequencyLimit
|
resp.Code = "10101"
|
||||||
resp.Message = "Decline due to amount or frequency limit"
|
resp.Message = "Decline due to amount or frequency limit"
|
||||||
body, _ := json.Marshal(resp)
|
body, _ := json.Marshal(resp)
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
@@ -554,7 +616,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
|
|||||||
)
|
)
|
||||||
defer processor.stopRetries()
|
defer processor.stopRetries()
|
||||||
processor.dispatchThrottleInterval = 0
|
processor.dispatchThrottleInterval = 0
|
||||||
processor.retryDelayFn = func(uint32) time.Duration { return 10 * time.Millisecond }
|
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 10 * time.Millisecond }
|
||||||
|
|
||||||
req := validCardPayoutRequest()
|
req := validCardPayoutRequest()
|
||||||
resp, err := processor.Submit(context.Background(), req)
|
resp, err := processor.Submit(context.Background(), req)
|
||||||
@@ -581,7 +643,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *testing.T) {
|
func TestCardPayoutProcessor_Submit_ProviderRetryUsesDelayedStrategy(t *testing.T) {
|
||||||
cfg := monetix.Config{
|
cfg := monetix.Config{
|
||||||
BaseURL: "https://monetix.test",
|
BaseURL: "https://monetix.test",
|
||||||
SecretKey: "secret",
|
SecretKey: "secret",
|
||||||
@@ -590,12 +652,10 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
|
|||||||
}
|
}
|
||||||
|
|
||||||
repo := newMockRepository()
|
repo := newMockRepository()
|
||||||
var calls atomic.Int32
|
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
_ = calls.Add(1)
|
|
||||||
resp := monetix.APIResponse{
|
resp := monetix.APIResponse{
|
||||||
Code: providerCodeDeclineAmountOrFrequencyLimit,
|
Code: "10101",
|
||||||
Message: "Decline due to amount or frequency limit",
|
Message: "Decline due to amount or frequency limit",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(resp)
|
body, _ := json.Marshal(resp)
|
||||||
@@ -617,7 +677,159 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
|
|||||||
)
|
)
|
||||||
defer processor.stopRetries()
|
defer processor.stopRetries()
|
||||||
processor.dispatchThrottleInterval = 0
|
processor.dispatchThrottleInterval = 0
|
||||||
processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond }
|
|
||||||
|
capturedStrategy := payoutRetryStrategy(0)
|
||||||
|
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
|
||||||
|
capturedStrategy = strategy
|
||||||
|
return time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("submit returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.GetAccepted() {
|
||||||
|
t.Fatalf("expected accepted response when retry is scheduled")
|
||||||
|
}
|
||||||
|
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyDelayed {
|
||||||
|
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyDelayed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardPayoutProcessor_Submit_StatusRefreshRetryUsesStatusRefreshStrategy(t *testing.T) {
|
||||||
|
cfg := monetix.Config{
|
||||||
|
BaseURL: "https://monetix.test",
|
||||||
|
SecretKey: "secret",
|
||||||
|
ProjectID: 99,
|
||||||
|
AllowedCurrencies: []string{"RUB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := newMockRepository()
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
resp := monetix.APIResponse{
|
||||||
|
Code: "3061",
|
||||||
|
Message: "Transaction not found",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resp)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusBadRequest,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
processor := newCardPayoutProcessor(
|
||||||
|
zap.NewNop(),
|
||||||
|
cfg,
|
||||||
|
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||||
|
repo,
|
||||||
|
httpClient,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
defer processor.stopRetries()
|
||||||
|
processor.dispatchThrottleInterval = 0
|
||||||
|
|
||||||
|
capturedStrategy := payoutRetryStrategy(0)
|
||||||
|
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
|
||||||
|
capturedStrategy = strategy
|
||||||
|
return time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("submit returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.GetAccepted() {
|
||||||
|
t.Fatalf("expected accepted response when retry is scheduled")
|
||||||
|
}
|
||||||
|
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyStatusRefresh {
|
||||||
|
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyStatusRefresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardPayoutProcessor_Submit_TransportRetryUsesImmediateStrategy(t *testing.T) {
|
||||||
|
cfg := monetix.Config{
|
||||||
|
BaseURL: "https://monetix.test",
|
||||||
|
SecretKey: "secret",
|
||||||
|
ProjectID: 99,
|
||||||
|
AllowedCurrencies: []string{"RUB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := newMockRepository()
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("transport timeout")
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
processor := newCardPayoutProcessor(
|
||||||
|
zap.NewNop(),
|
||||||
|
cfg,
|
||||||
|
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||||
|
repo,
|
||||||
|
httpClient,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
defer processor.stopRetries()
|
||||||
|
processor.dispatchThrottleInterval = 0
|
||||||
|
|
||||||
|
capturedStrategy := payoutRetryStrategy(0)
|
||||||
|
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
|
||||||
|
capturedStrategy = strategy
|
||||||
|
return time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("submit returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.GetAccepted() {
|
||||||
|
t.Fatalf("expected accepted response when retry is scheduled")
|
||||||
|
}
|
||||||
|
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyImmediate {
|
||||||
|
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyImmediate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttention(t *testing.T) {
|
||||||
|
cfg := monetix.Config{
|
||||||
|
BaseURL: "https://monetix.test",
|
||||||
|
SecretKey: "secret",
|
||||||
|
ProjectID: 99,
|
||||||
|
AllowedCurrencies: []string{"RUB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := newMockRepository()
|
||||||
|
var calls atomic.Int32
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
_ = calls.Add(1)
|
||||||
|
resp := monetix.APIResponse{
|
||||||
|
Code: "10101",
|
||||||
|
Message: "Decline due to amount or frequency limit",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resp)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusTooManyRequests,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
processor := newCardPayoutProcessor(
|
||||||
|
zap.NewNop(),
|
||||||
|
cfg,
|
||||||
|
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||||
|
repo,
|
||||||
|
httpClient,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
defer processor.stopRetries()
|
||||||
|
processor.dispatchThrottleInterval = 0
|
||||||
|
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return time.Millisecond }
|
||||||
|
|
||||||
req := validCardPayoutRequest()
|
req := validCardPayoutRequest()
|
||||||
resp, err := processor.Submit(context.Background(), req)
|
resp, err := processor.Submit(context.Background(), req)
|
||||||
@@ -631,14 +843,14 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
|
|||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for {
|
for {
|
||||||
state, ok := repo.payouts.Get(req.GetPayoutId())
|
state, ok := repo.payouts.Get(req.GetPayoutId())
|
||||||
if ok && state != nil && state.Status == model.PayoutStatusFailed {
|
if ok && state != nil && state.Status == model.PayoutStatusNeedsAttention {
|
||||||
if !strings.Contains(state.FailureReason, providerCodeDeclineAmountOrFrequencyLimit) {
|
if !strings.Contains(state.FailureReason, "10101") {
|
||||||
t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason)
|
t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
t.Fatalf("timeout waiting for terminal failed status")
|
t.Fatalf("timeout waiting for terminal needs_attention status")
|
||||||
}
|
}
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
@@ -647,6 +859,59 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCardPayoutProcessor_Submit_NonRetryProviderDeclineRemainsFailed(t *testing.T) {
|
||||||
|
cfg := monetix.Config{
|
||||||
|
BaseURL: "https://monetix.test",
|
||||||
|
SecretKey: "secret",
|
||||||
|
ProjectID: 99,
|
||||||
|
AllowedCurrencies: []string{"RUB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := newMockRepository()
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
resp := monetix.APIResponse{
|
||||||
|
Code: "10003",
|
||||||
|
Message: "Decline by anti-fraud policy",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resp)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusBadRequest,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
processor := newCardPayoutProcessor(
|
||||||
|
zap.NewNop(),
|
||||||
|
cfg,
|
||||||
|
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||||
|
repo,
|
||||||
|
httpClient,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
defer processor.stopRetries()
|
||||||
|
processor.dispatchThrottleInterval = 0
|
||||||
|
|
||||||
|
req := validCardPayoutRequest()
|
||||||
|
resp, err := processor.Submit(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("submit returned error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.GetAccepted() {
|
||||||
|
t.Fatalf("expected non-accepted response for non-retryable provider decline")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, ok := repo.payouts.Get(req.GetPayoutId())
|
||||||
|
if !ok || state == nil {
|
||||||
|
t.Fatal("expected stored payout state")
|
||||||
|
}
|
||||||
|
if got, want := state.Status, model.PayoutStatusFailed; got != want {
|
||||||
|
t.Fatalf("unexpected payout status: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) {
|
func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) {
|
||||||
cfg := monetix.Config{
|
cfg := monetix.Config{
|
||||||
BaseURL: "https://monetix.test",
|
BaseURL: "https://monetix.test",
|
||||||
@@ -687,7 +952,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
|
|||||||
)
|
)
|
||||||
defer processor.stopRetries()
|
defer processor.stopRetries()
|
||||||
processor.dispatchThrottleInterval = 0
|
processor.dispatchThrottleInterval = 0
|
||||||
processor.retryDelayFn = func(uint32) time.Duration { return 5 * time.Millisecond }
|
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 5 * time.Millisecond }
|
||||||
|
|
||||||
req := validCardPayoutRequest()
|
req := validCardPayoutRequest()
|
||||||
resp, err := processor.Submit(context.Background(), req)
|
resp, err := processor.Submit(context.Background(), req)
|
||||||
@@ -702,7 +967,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
|
|||||||
cb.Payment.ID = req.GetPayoutId()
|
cb.Payment.ID = req.GetPayoutId()
|
||||||
cb.Payment.Status = "failed"
|
cb.Payment.Status = "failed"
|
||||||
cb.Operation.Status = "failed"
|
cb.Operation.Status = "failed"
|
||||||
cb.Operation.Code = providerCodeDeclineAmountOrFrequencyLimit
|
cb.Operation.Code = "10101"
|
||||||
cb.Operation.Message = "Decline due to amount or frequency limit"
|
cb.Operation.Message = "Decline due to amount or frequency limit"
|
||||||
cb.Payment.Sum.Currency = "RUB"
|
cb.Payment.Sum.Currency = "RUB"
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus {
|
|||||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
|
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
|
||||||
case model.PayoutStatusFailed:
|
case model.PayoutStatusFailed:
|
||||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
|
case model.PayoutStatusNeedsAttention:
|
||||||
|
// Connector/gateway proto does not expose needs_attention yet; map it to failed externally.
|
||||||
|
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
case model.PayoutStatusCancelled:
|
case model.PayoutStatusCancelled:
|
||||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
|
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPayoutStatusToProto_NeedsAttentionMapsToFailed(t *testing.T) {
|
||||||
|
if got, want := payoutStatusToProto(model.PayoutStatusNeedsAttention), mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED; got != want {
|
||||||
|
t.Fatalf("unexpected proto status: got=%v want=%v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
providerCodeDeclineAmountOrFrequencyLimit = "10101"
|
|
||||||
)
|
|
||||||
|
|
||||||
type payoutFailureAction int
|
type payoutFailureAction int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -15,47 +13,137 @@ const (
|
|||||||
payoutFailureActionRetry
|
payoutFailureActionRetry
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type payoutRetryStrategy int
|
||||||
|
|
||||||
|
const (
|
||||||
|
payoutRetryStrategyImmediate payoutRetryStrategy = iota + 1
|
||||||
|
payoutRetryStrategyDelayed
|
||||||
|
payoutRetryStrategyStatusRefresh
|
||||||
|
)
|
||||||
|
|
||||||
type payoutFailureDecision struct {
|
type payoutFailureDecision struct {
|
||||||
Action payoutFailureAction
|
Action payoutFailureAction
|
||||||
Reason string
|
Strategy payoutRetryStrategy
|
||||||
|
Reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
type payoutFailurePolicy struct {
|
type payoutFailurePolicy struct {
|
||||||
providerCodeActions map[string]payoutFailureAction
|
providerCodeStrategies map[string]payoutRetryStrategy
|
||||||
|
documentedProviderCodes map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultPayoutFailurePolicy() payoutFailurePolicy {
|
type retryCodeBucket struct {
|
||||||
return payoutFailurePolicy{
|
strategy payoutRetryStrategy
|
||||||
providerCodeActions: map[string]payoutFailureAction{
|
retryable bool
|
||||||
providerCodeDeclineAmountOrFrequencyLimit: payoutFailureActionRetry,
|
codes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerRetryOnlyCodeBuckets = []retryCodeBucket{
|
||||||
|
// GTX "repeat request now / temporary issue" style codes.
|
||||||
|
{
|
||||||
|
strategy: payoutRetryStrategyImmediate,
|
||||||
|
retryable: true,
|
||||||
|
codes: []string{
|
||||||
|
// General codes.
|
||||||
|
"104", "108", "301", "320", "601", "602", "603", "3025", "3198",
|
||||||
|
// External card PS codes.
|
||||||
|
"10000", "10100", "10104", "10105", "10107", "10202", "102051", "10301", "105012", "10505", "10601", "10602", "10603",
|
||||||
|
// External alternate PS codes.
|
||||||
|
"20000", "20100", "20104", "20105", "20202", "20301", "20304", "20601", "20602", "20603",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
// GTX "retry later / limits / period restrictions" style codes.
|
||||||
|
{
|
||||||
|
strategy: payoutRetryStrategyDelayed,
|
||||||
|
retryable: true,
|
||||||
|
codes: []string{
|
||||||
|
// General codes.
|
||||||
|
"312", "314", "315", "316", "325", "2466",
|
||||||
|
"3106", "3108", "3109", "3110", "3111", "3112",
|
||||||
|
"3285", "3297", "3298",
|
||||||
|
"3305", "3306", "3307", "3308", "3309", "3310", "3311", "3312", "3313", "3314", "3315", "3316", "3317", "3318", "3319", "3320", "3321", "3322", "3323", "3324", "3325", "3326", "3327", "3328", "3329", "3330", "3331", "3332", "3333", "3334", "3335", "3336", "3337", "3338", "3339", "3340",
|
||||||
|
"3342", "3343", "3344", "3345", "3346", "3347", "3348", "3349", "3350", "3351", "3352", "3353", "3355", "3357",
|
||||||
|
"3407", "3408", "3450", "3451", "3452", "3613",
|
||||||
|
// External card PS codes.
|
||||||
|
"10101", "10109", "10112", "10114", "101012", "101013", "101014",
|
||||||
|
// External alternate PS codes.
|
||||||
|
"20109", "20206", "20505", "201012", "201013", "201014",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// GTX status refresh/polling conditions.
|
||||||
|
{
|
||||||
|
strategy: payoutRetryStrategyStatusRefresh,
|
||||||
|
retryable: true,
|
||||||
|
codes: []string{
|
||||||
|
"3061", "3062",
|
||||||
|
"9999", "19999", "20802", "29999",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerDocumentedNonRetryCodes = buildDocumentedNonRetryCodes(providerDocumentedCodes, providerRetryOnlyCodeBuckets)
|
||||||
|
|
||||||
|
var providerRetryCodeBuckets = func() []retryCodeBucket {
|
||||||
|
buckets := make([]retryCodeBucket, 0, len(providerRetryOnlyCodeBuckets)+1)
|
||||||
|
buckets = append(buckets, providerRetryOnlyCodeBuckets...)
|
||||||
|
buckets = append(buckets, retryCodeBucket{
|
||||||
|
strategy: payoutRetryStrategyImmediate,
|
||||||
|
retryable: false,
|
||||||
|
codes: providerDocumentedNonRetryCodes,
|
||||||
|
})
|
||||||
|
return buckets
|
||||||
|
}()
|
||||||
|
|
||||||
|
func defaultPayoutFailurePolicy() payoutFailurePolicy {
|
||||||
|
strategies := map[string]payoutRetryStrategy{}
|
||||||
|
for _, bucket := range providerRetryCodeBuckets {
|
||||||
|
if !bucket.retryable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
registerRetryStrategy(strategies, bucket.strategy, bucket.codes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payoutFailurePolicy{
|
||||||
|
providerCodeStrategies: strategies,
|
||||||
|
documentedProviderCodes: newCodeSet(providerDocumentedCodes),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision {
|
func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision {
|
||||||
normalized := strings.TrimSpace(code)
|
normalized := normalizeProviderCode(code)
|
||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
return payoutFailureDecision{
|
return payoutFailureDecision{
|
||||||
Action: payoutFailureActionFail,
|
Action: payoutFailureActionFail,
|
||||||
Reason: "provider_failure",
|
Strategy: payoutRetryStrategyImmediate,
|
||||||
|
Reason: "provider_failure",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if action, ok := p.providerCodeActions[normalized]; ok {
|
if strategy, ok := p.providerCodeStrategies[normalized]; ok {
|
||||||
return payoutFailureDecision{
|
return payoutFailureDecision{
|
||||||
Action: action,
|
Action: payoutFailureActionRetry,
|
||||||
Reason: "provider_code_" + normalized,
|
Strategy: strategy,
|
||||||
|
Reason: "provider_code_" + normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := p.documentedProviderCodes[normalized]; ok {
|
||||||
|
return payoutFailureDecision{
|
||||||
|
Action: payoutFailureActionFail,
|
||||||
|
Strategy: payoutRetryStrategyImmediate,
|
||||||
|
Reason: "provider_code_" + normalized + "_documented_non_retry",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return payoutFailureDecision{
|
return payoutFailureDecision{
|
||||||
Action: payoutFailureActionFail,
|
Action: payoutFailureActionFail,
|
||||||
Reason: "provider_code_" + normalized,
|
Strategy: payoutRetryStrategyImmediate,
|
||||||
|
Reason: "provider_code_" + normalized + "_unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
|
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
|
||||||
return payoutFailureDecision{
|
return payoutFailureDecision{
|
||||||
Action: payoutFailureActionRetry,
|
Action: payoutFailureActionRetry,
|
||||||
Reason: "transport_failure",
|
Strategy: payoutRetryStrategyImmediate,
|
||||||
|
Reason: "transport_failure",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +160,40 @@ func payoutFailureReason(code, message string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func retryDelayForAttempt(attempt uint32) int {
|
func retryDelayForAttempt(attempt uint32, strategy payoutRetryStrategy) int {
|
||||||
// Backoff in seconds by attempt number (attempt starts at 1).
|
strategy = normalizeRetryStrategy(strategy)
|
||||||
|
|
||||||
|
// Backoff in seconds by strategy and attempt number (attempt starts at 1).
|
||||||
|
if strategy == payoutRetryStrategyStatusRefresh {
|
||||||
|
switch {
|
||||||
|
case attempt <= 1:
|
||||||
|
return 10
|
||||||
|
case attempt == 2:
|
||||||
|
return 20
|
||||||
|
case attempt == 3:
|
||||||
|
return 40
|
||||||
|
case attempt == 4:
|
||||||
|
return 80
|
||||||
|
default:
|
||||||
|
return 160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strategy == payoutRetryStrategyDelayed {
|
||||||
|
switch {
|
||||||
|
case attempt <= 1:
|
||||||
|
return 30
|
||||||
|
case attempt == 2:
|
||||||
|
return 120
|
||||||
|
case attempt == 3:
|
||||||
|
return 600
|
||||||
|
case attempt == 4:
|
||||||
|
return 1800
|
||||||
|
default:
|
||||||
|
return 7200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case attempt <= 1:
|
case attempt <= 1:
|
||||||
return 5
|
return 5
|
||||||
@@ -85,3 +205,86 @@ func retryDelayForAttempt(attempt uint32) int {
|
|||||||
return 60
|
return 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerRetryStrategy(dst map[string]payoutRetryStrategy, strategy payoutRetryStrategy, codes ...string) {
|
||||||
|
if dst == nil || len(codes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
strategy = normalizeRetryStrategy(strategy)
|
||||||
|
for _, code := range codes {
|
||||||
|
normalized := normalizeProviderCode(code)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dst[normalized] = strategy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCodeSet(codes []string) map[string]struct{} {
|
||||||
|
set := map[string]struct{}{}
|
||||||
|
for _, code := range codes {
|
||||||
|
normalized := normalizeProviderCode(code)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set[normalized] = struct{}{}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDocumentedNonRetryCodes(documented []string, retryBuckets []retryCodeBucket) []string {
|
||||||
|
documentedSet := newCodeSet(documented)
|
||||||
|
retrySet := map[string]struct{}{}
|
||||||
|
for _, bucket := range retryBuckets {
|
||||||
|
for _, code := range bucket.codes {
|
||||||
|
normalized := normalizeProviderCode(code)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
retrySet[normalized] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonRetry := make([]string, 0, len(documentedSet))
|
||||||
|
for code := range documentedSet {
|
||||||
|
if _, ok := retrySet[code]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nonRetry = append(nonRetry, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(nonRetry, func(i, j int) bool {
|
||||||
|
left, leftErr := strconv.Atoi(nonRetry[i])
|
||||||
|
right, rightErr := strconv.Atoi(nonRetry[j])
|
||||||
|
if leftErr != nil || rightErr != nil {
|
||||||
|
return nonRetry[i] < nonRetry[j]
|
||||||
|
}
|
||||||
|
return left < right
|
||||||
|
})
|
||||||
|
|
||||||
|
return nonRetry
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProviderCode(code string) string {
|
||||||
|
return strings.TrimSpace(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRetryStrategy(strategy payoutRetryStrategy) payoutRetryStrategy {
|
||||||
|
switch strategy {
|
||||||
|
case payoutRetryStrategyDelayed, payoutRetryStrategyStatusRefresh:
|
||||||
|
return strategy
|
||||||
|
default:
|
||||||
|
return payoutRetryStrategyImmediate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s payoutRetryStrategy) String() string {
|
||||||
|
switch normalizeRetryStrategy(s) {
|
||||||
|
case payoutRetryStrategyDelayed:
|
||||||
|
return "delayed"
|
||||||
|
case payoutRetryStrategyStatusRefresh:
|
||||||
|
return "status_refresh"
|
||||||
|
default:
|
||||||
|
return "immediate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
// providerDocumentedCodes is the normalized list of numeric response codes documented in
|
||||||
|
// https://developers.gtxpoint.com/ru/ru_gate_statuses_and_response_codes.html
|
||||||
|
// (all response-code tables).
|
||||||
|
var providerDocumentedCodes = []string{
|
||||||
|
"0",
|
||||||
|
"100",
|
||||||
|
"104",
|
||||||
|
"108",
|
||||||
|
"109",
|
||||||
|
"301",
|
||||||
|
"303",
|
||||||
|
"309",
|
||||||
|
"310",
|
||||||
|
"311",
|
||||||
|
"312",
|
||||||
|
"313",
|
||||||
|
"314",
|
||||||
|
"315",
|
||||||
|
"316",
|
||||||
|
"320",
|
||||||
|
"325",
|
||||||
|
"402",
|
||||||
|
"501",
|
||||||
|
"502",
|
||||||
|
"504",
|
||||||
|
"601",
|
||||||
|
"602",
|
||||||
|
"603",
|
||||||
|
"702",
|
||||||
|
"903",
|
||||||
|
"904",
|
||||||
|
"1337",
|
||||||
|
"1401",
|
||||||
|
"1402",
|
||||||
|
"1403",
|
||||||
|
"1404",
|
||||||
|
"1405",
|
||||||
|
"1406",
|
||||||
|
"1407",
|
||||||
|
"1408",
|
||||||
|
"1409",
|
||||||
|
"1410",
|
||||||
|
"1411",
|
||||||
|
"1412",
|
||||||
|
"1413",
|
||||||
|
"1415",
|
||||||
|
"1416",
|
||||||
|
"1417",
|
||||||
|
"1418",
|
||||||
|
"1419",
|
||||||
|
"1420",
|
||||||
|
"1421",
|
||||||
|
"1422",
|
||||||
|
"1423",
|
||||||
|
"1424",
|
||||||
|
"1425",
|
||||||
|
"1426",
|
||||||
|
"1427",
|
||||||
|
"1428",
|
||||||
|
"1429",
|
||||||
|
"1430",
|
||||||
|
"1431",
|
||||||
|
"1432",
|
||||||
|
"1433",
|
||||||
|
"1434",
|
||||||
|
"1435",
|
||||||
|
"1436",
|
||||||
|
"1437",
|
||||||
|
"1438",
|
||||||
|
"1439",
|
||||||
|
"1441",
|
||||||
|
"1451",
|
||||||
|
"1452",
|
||||||
|
"1453",
|
||||||
|
"1454",
|
||||||
|
"1455",
|
||||||
|
"1456",
|
||||||
|
"1457",
|
||||||
|
"1461",
|
||||||
|
"1462",
|
||||||
|
"1463",
|
||||||
|
"1464",
|
||||||
|
"1499",
|
||||||
|
"2003",
|
||||||
|
"2004",
|
||||||
|
"2005",
|
||||||
|
"2008",
|
||||||
|
"2014",
|
||||||
|
"2061",
|
||||||
|
"2123",
|
||||||
|
"2124",
|
||||||
|
"2154",
|
||||||
|
"2164",
|
||||||
|
"2261",
|
||||||
|
"2426",
|
||||||
|
"2442",
|
||||||
|
"2466",
|
||||||
|
"2541",
|
||||||
|
"2606",
|
||||||
|
"2609",
|
||||||
|
"2610",
|
||||||
|
"2611",
|
||||||
|
"2641",
|
||||||
|
"2642",
|
||||||
|
"2701",
|
||||||
|
"2801",
|
||||||
|
"2881",
|
||||||
|
"2945",
|
||||||
|
"2949",
|
||||||
|
"3001",
|
||||||
|
"3002",
|
||||||
|
"3003",
|
||||||
|
"3004",
|
||||||
|
"3019",
|
||||||
|
"3020",
|
||||||
|
"3021",
|
||||||
|
"3022",
|
||||||
|
"3023",
|
||||||
|
"3024",
|
||||||
|
"3025",
|
||||||
|
"3026",
|
||||||
|
"3027",
|
||||||
|
"3028",
|
||||||
|
"3029",
|
||||||
|
"3030",
|
||||||
|
"3041",
|
||||||
|
"3059",
|
||||||
|
"3060",
|
||||||
|
"3061",
|
||||||
|
"3062",
|
||||||
|
"3081",
|
||||||
|
"3101",
|
||||||
|
"3102",
|
||||||
|
"3103",
|
||||||
|
"3104",
|
||||||
|
"3105",
|
||||||
|
"3106",
|
||||||
|
"3107",
|
||||||
|
"3108",
|
||||||
|
"3109",
|
||||||
|
"3110",
|
||||||
|
"3111",
|
||||||
|
"3112",
|
||||||
|
"3118",
|
||||||
|
"3119",
|
||||||
|
"3120",
|
||||||
|
"3121",
|
||||||
|
"3122",
|
||||||
|
"3123",
|
||||||
|
"3124",
|
||||||
|
"3141",
|
||||||
|
"3161",
|
||||||
|
"3181",
|
||||||
|
"3182",
|
||||||
|
"3183",
|
||||||
|
"3184",
|
||||||
|
"3191",
|
||||||
|
"3192",
|
||||||
|
"3193",
|
||||||
|
"3194",
|
||||||
|
"3195",
|
||||||
|
"3196",
|
||||||
|
"3197",
|
||||||
|
"3198",
|
||||||
|
"3199",
|
||||||
|
"3200",
|
||||||
|
"3201",
|
||||||
|
"3221",
|
||||||
|
"3230",
|
||||||
|
"3241",
|
||||||
|
"3242",
|
||||||
|
"3243",
|
||||||
|
"3244",
|
||||||
|
"3261",
|
||||||
|
"3262",
|
||||||
|
"3281",
|
||||||
|
"3283",
|
||||||
|
"3284",
|
||||||
|
"3285",
|
||||||
|
"3286",
|
||||||
|
"3287",
|
||||||
|
"3288",
|
||||||
|
"3289",
|
||||||
|
"3291",
|
||||||
|
"3292",
|
||||||
|
"3293",
|
||||||
|
"3297",
|
||||||
|
"3298",
|
||||||
|
"3299",
|
||||||
|
"3301",
|
||||||
|
"3303",
|
||||||
|
"3304",
|
||||||
|
"3305",
|
||||||
|
"3306",
|
||||||
|
"3307",
|
||||||
|
"3308",
|
||||||
|
"3309",
|
||||||
|
"3310",
|
||||||
|
"3311",
|
||||||
|
"3312",
|
||||||
|
"3313",
|
||||||
|
"3314",
|
||||||
|
"3315",
|
||||||
|
"3316",
|
||||||
|
"3317",
|
||||||
|
"3318",
|
||||||
|
"3319",
|
||||||
|
"3320",
|
||||||
|
"3321",
|
||||||
|
"3322",
|
||||||
|
"3323",
|
||||||
|
"3324",
|
||||||
|
"3325",
|
||||||
|
"3326",
|
||||||
|
"3327",
|
||||||
|
"3328",
|
||||||
|
"3329",
|
||||||
|
"3330",
|
||||||
|
"3331",
|
||||||
|
"3332",
|
||||||
|
"3333",
|
||||||
|
"3334",
|
||||||
|
"3335",
|
||||||
|
"3336",
|
||||||
|
"3337",
|
||||||
|
"3338",
|
||||||
|
"3339",
|
||||||
|
"3340",
|
||||||
|
"3341",
|
||||||
|
"3342",
|
||||||
|
"3343",
|
||||||
|
"3344",
|
||||||
|
"3345",
|
||||||
|
"3346",
|
||||||
|
"3347",
|
||||||
|
"3348",
|
||||||
|
"3349",
|
||||||
|
"3350",
|
||||||
|
"3351",
|
||||||
|
"3352",
|
||||||
|
"3353",
|
||||||
|
"3355",
|
||||||
|
"3356",
|
||||||
|
"3357",
|
||||||
|
"3358",
|
||||||
|
"3360",
|
||||||
|
"3400",
|
||||||
|
"3402",
|
||||||
|
"3403",
|
||||||
|
"3404",
|
||||||
|
"3405",
|
||||||
|
"3406",
|
||||||
|
"3407",
|
||||||
|
"3408",
|
||||||
|
"3409",
|
||||||
|
"3410",
|
||||||
|
"3411",
|
||||||
|
"3412",
|
||||||
|
"3413",
|
||||||
|
"3414",
|
||||||
|
"3415",
|
||||||
|
"3416",
|
||||||
|
"3417",
|
||||||
|
"3418",
|
||||||
|
"3419",
|
||||||
|
"3431",
|
||||||
|
"3432",
|
||||||
|
"3433",
|
||||||
|
"3434",
|
||||||
|
"3435",
|
||||||
|
"3436",
|
||||||
|
"3437",
|
||||||
|
"3438",
|
||||||
|
"3439",
|
||||||
|
"3450",
|
||||||
|
"3451",
|
||||||
|
"3452",
|
||||||
|
"3470",
|
||||||
|
"3471",
|
||||||
|
"3472",
|
||||||
|
"3480",
|
||||||
|
"3485",
|
||||||
|
"3490",
|
||||||
|
"3491",
|
||||||
|
"3609",
|
||||||
|
"3610",
|
||||||
|
"3611",
|
||||||
|
"3612",
|
||||||
|
"3613",
|
||||||
|
"9999",
|
||||||
|
"10000",
|
||||||
|
"10100",
|
||||||
|
"10101",
|
||||||
|
"10102",
|
||||||
|
"10103",
|
||||||
|
"10104",
|
||||||
|
"10105",
|
||||||
|
"10106",
|
||||||
|
"10107",
|
||||||
|
"10108",
|
||||||
|
"10109",
|
||||||
|
"10110",
|
||||||
|
"10111",
|
||||||
|
"10112",
|
||||||
|
"10113",
|
||||||
|
"10114",
|
||||||
|
"10201",
|
||||||
|
"10202",
|
||||||
|
"10203",
|
||||||
|
"10204",
|
||||||
|
"10205",
|
||||||
|
"10301",
|
||||||
|
"10401",
|
||||||
|
"10402",
|
||||||
|
"10403",
|
||||||
|
"10404",
|
||||||
|
"10405",
|
||||||
|
"10501",
|
||||||
|
"10502",
|
||||||
|
"10503",
|
||||||
|
"10504",
|
||||||
|
"10505",
|
||||||
|
"10601",
|
||||||
|
"10602",
|
||||||
|
"10603",
|
||||||
|
"10701",
|
||||||
|
"10702",
|
||||||
|
"10703",
|
||||||
|
"10704",
|
||||||
|
"10705",
|
||||||
|
"10706",
|
||||||
|
"10707",
|
||||||
|
"10708",
|
||||||
|
"10709",
|
||||||
|
"10722",
|
||||||
|
"10801",
|
||||||
|
"10805",
|
||||||
|
"10806",
|
||||||
|
"10807",
|
||||||
|
"10811",
|
||||||
|
"10812",
|
||||||
|
"19999",
|
||||||
|
"20000",
|
||||||
|
"20100",
|
||||||
|
"20101",
|
||||||
|
"20102",
|
||||||
|
"20103",
|
||||||
|
"20104",
|
||||||
|
"20105",
|
||||||
|
"20106",
|
||||||
|
"20107",
|
||||||
|
"20109",
|
||||||
|
"20201",
|
||||||
|
"20202",
|
||||||
|
"20203",
|
||||||
|
"20204",
|
||||||
|
"20205",
|
||||||
|
"20206",
|
||||||
|
"20301",
|
||||||
|
"20302",
|
||||||
|
"20303",
|
||||||
|
"20304",
|
||||||
|
"20401",
|
||||||
|
"20402",
|
||||||
|
"20501",
|
||||||
|
"20502",
|
||||||
|
"20503",
|
||||||
|
"20504",
|
||||||
|
"20505",
|
||||||
|
"20601",
|
||||||
|
"20602",
|
||||||
|
"20603",
|
||||||
|
"20604",
|
||||||
|
"20701",
|
||||||
|
"20702",
|
||||||
|
"20703",
|
||||||
|
"20705",
|
||||||
|
"20706",
|
||||||
|
"20801",
|
||||||
|
"20802",
|
||||||
|
"29999",
|
||||||
|
"30000",
|
||||||
|
"30100",
|
||||||
|
"30301",
|
||||||
|
"30302",
|
||||||
|
"30303",
|
||||||
|
"30401",
|
||||||
|
"101011",
|
||||||
|
"101012",
|
||||||
|
"101013",
|
||||||
|
"101014",
|
||||||
|
"101021",
|
||||||
|
"102051",
|
||||||
|
"105012",
|
||||||
|
"108010",
|
||||||
|
"201011",
|
||||||
|
"201012",
|
||||||
|
"201013",
|
||||||
|
"201014",
|
||||||
|
}
|
||||||
@@ -2,28 +2,73 @@ package gateway
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
func retryBucketCodeSet() map[string]struct{} {
|
||||||
|
set := map[string]struct{}{}
|
||||||
|
for _, bucket := range providerRetryCodeBuckets {
|
||||||
|
if !bucket.retryable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, code := range bucket.codes {
|
||||||
|
set[normalizeProviderCode(code)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
func allBucketCodeSet() map[string]struct{} {
|
||||||
|
set := map[string]struct{}{}
|
||||||
|
for _, bucket := range providerRetryCodeBuckets {
|
||||||
|
for _, code := range bucket.codes {
|
||||||
|
set[normalizeProviderCode(code)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
|
func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
|
||||||
policy := defaultPayoutFailurePolicy()
|
policy := defaultPayoutFailurePolicy()
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
code string
|
code string
|
||||||
action payoutFailureAction
|
action payoutFailureAction
|
||||||
|
strategy payoutRetryStrategy
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "retryable provider limit code",
|
name: "immediate retry strategy code",
|
||||||
code: providerCodeDeclineAmountOrFrequencyLimit,
|
code: "10000",
|
||||||
action: payoutFailureActionRetry,
|
action: payoutFailureActionRetry,
|
||||||
|
strategy: payoutRetryStrategyImmediate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown provider code",
|
name: "delayed retry strategy code",
|
||||||
code: "99999",
|
code: "10101",
|
||||||
action: payoutFailureActionFail,
|
action: payoutFailureActionRetry,
|
||||||
|
strategy: payoutRetryStrategyDelayed,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty provider code",
|
name: "status refresh retry strategy code",
|
||||||
code: "",
|
code: "3061",
|
||||||
action: payoutFailureActionFail,
|
action: payoutFailureActionRetry,
|
||||||
|
strategy: payoutRetryStrategyStatusRefresh,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status refresh retry strategy payment details missing code",
|
||||||
|
code: "3062",
|
||||||
|
action: payoutFailureActionRetry,
|
||||||
|
strategy: payoutRetryStrategyStatusRefresh,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown provider code",
|
||||||
|
code: "99999",
|
||||||
|
action: payoutFailureActionFail,
|
||||||
|
strategy: payoutRetryStrategyImmediate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty provider code",
|
||||||
|
code: "",
|
||||||
|
action: payoutFailureActionFail,
|
||||||
|
strategy: payoutRetryStrategyImmediate,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +80,204 @@ func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
|
|||||||
if got.Action != tc.action {
|
if got.Action != tc.action {
|
||||||
t.Fatalf("action mismatch: got=%v want=%v", got.Action, tc.action)
|
t.Fatalf("action mismatch: got=%v want=%v", got.Action, tc.action)
|
||||||
}
|
}
|
||||||
|
if got.Strategy != tc.strategy {
|
||||||
|
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, tc.strategy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayoutFailurePolicy_DocumentRetryCoverage(t *testing.T) {
|
||||||
|
policy := defaultPayoutFailurePolicy()
|
||||||
|
|
||||||
|
// Parsed from GTX response-code tables (General, RCS, external card PS, external alternate PS, merchant system):
|
||||||
|
// 32 immediate + 84 delayed + 6 status-refresh = 122 retryable codes.
|
||||||
|
if got, want := len(policy.providerCodeStrategies), 122; got != want {
|
||||||
|
t.Fatalf("retry catalog size mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := len(policy.documentedProviderCodes), 395; got != want {
|
||||||
|
t.Fatalf("documented code catalog size mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
code string
|
||||||
|
strategy payoutRetryStrategy
|
||||||
|
}{
|
||||||
|
// Immediate retry examples.
|
||||||
|
{code: "3025", strategy: payoutRetryStrategyImmediate},
|
||||||
|
{code: "3198", strategy: payoutRetryStrategyImmediate},
|
||||||
|
{code: "105012", strategy: payoutRetryStrategyImmediate},
|
||||||
|
{code: "20603", strategy: payoutRetryStrategyImmediate},
|
||||||
|
// Delayed retry examples, including previously missed high-range limits.
|
||||||
|
{code: "3106", strategy: payoutRetryStrategyDelayed},
|
||||||
|
{code: "3337", strategy: payoutRetryStrategyDelayed},
|
||||||
|
{code: "3407", strategy: payoutRetryStrategyDelayed},
|
||||||
|
{code: "3613", strategy: payoutRetryStrategyDelayed},
|
||||||
|
{code: "201014", strategy: payoutRetryStrategyDelayed},
|
||||||
|
// Status refresh examples.
|
||||||
|
{code: "3061", strategy: payoutRetryStrategyStatusRefresh},
|
||||||
|
{code: "3062", strategy: payoutRetryStrategyStatusRefresh},
|
||||||
|
{code: "20802", strategy: payoutRetryStrategyStatusRefresh},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.code, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
got := policy.decideProviderFailure(tc.code)
|
||||||
|
if got.Action != payoutFailureActionRetry {
|
||||||
|
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionRetry)
|
||||||
|
}
|
||||||
|
if got.Strategy != tc.strategy {
|
||||||
|
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, tc.strategy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayoutFailurePolicy_DocumentedCodeCoverageByPolicy(t *testing.T) {
|
||||||
|
policy := defaultPayoutFailurePolicy()
|
||||||
|
retrySet := retryBucketCodeSet()
|
||||||
|
|
||||||
|
if got, want := len(retrySet), len(policy.providerCodeStrategies); got != want {
|
||||||
|
t.Fatalf("retry set size mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
documentedNonRetry := 0
|
||||||
|
for _, code := range providerDocumentedCodes {
|
||||||
|
code := normalizeProviderCode(code)
|
||||||
|
decision := policy.decideProviderFailure(code)
|
||||||
|
|
||||||
|
if _, isRetry := retrySet[code]; isRetry {
|
||||||
|
if decision.Action != payoutFailureActionRetry {
|
||||||
|
t.Fatalf("documented retry code %s unexpectedly classified as non-retry", code)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
documentedNonRetry++
|
||||||
|
if decision.Action != payoutFailureActionFail {
|
||||||
|
t.Fatalf("documented non-retry code %s unexpectedly classified as retry", code)
|
||||||
|
}
|
||||||
|
if decision.Reason != "provider_code_"+code+"_documented_non_retry" {
|
||||||
|
t.Fatalf("documented non-retry code %s has unexpected reason: %q", code, decision.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := len(retrySet)+documentedNonRetry, len(providerDocumentedCodes); got != want {
|
||||||
|
t.Fatalf("coverage mismatch: retry(%d)+non_retry(%d) != documented(%d)", len(retrySet), documentedNonRetry, len(providerDocumentedCodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderRetryCodeBuckets_DoNotOverlapAndCoverDocumentedCodes(t *testing.T) {
|
||||||
|
seen := map[string]int{}
|
||||||
|
for bucketIdx, bucket := range providerRetryCodeBuckets {
|
||||||
|
for _, rawCode := range bucket.codes {
|
||||||
|
code := normalizeProviderCode(rawCode)
|
||||||
|
if code == "" {
|
||||||
|
t.Fatalf("empty code in bucket #%d", bucketIdx)
|
||||||
|
}
|
||||||
|
if prevIdx, ok := seen[code]; ok {
|
||||||
|
t.Fatalf("overlap detected for code %s between bucket #%d and bucket #%d", code, prevIdx, bucketIdx)
|
||||||
|
}
|
||||||
|
seen[code] = bucketIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allBucketCodes := allBucketCodeSet()
|
||||||
|
documented := newCodeSet(providerDocumentedCodes)
|
||||||
|
if got, want := len(allBucketCodes), len(documented); got != want {
|
||||||
|
t.Fatalf("union size mismatch: buckets=%d documented=%d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for code := range documented {
|
||||||
|
if _, ok := allBucketCodes[code]; !ok {
|
||||||
|
t.Fatalf("documented code %s is missing from providerRetryCodeBuckets union", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for code := range allBucketCodes {
|
||||||
|
if _, ok := documented[code]; !ok {
|
||||||
|
t.Fatalf("bucket code %s is not present in documented code list", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayoutFailurePolicy_DecideProviderFailure_DocumentedNonRetryCode(t *testing.T) {
|
||||||
|
policy := defaultPayoutFailurePolicy()
|
||||||
|
|
||||||
|
got := policy.decideProviderFailure("3059")
|
||||||
|
if got.Action != payoutFailureActionFail {
|
||||||
|
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionFail)
|
||||||
|
}
|
||||||
|
if got.Strategy != payoutRetryStrategyImmediate {
|
||||||
|
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
|
||||||
|
}
|
||||||
|
if got.Reason != "provider_code_3059_documented_non_retry" {
|
||||||
|
t.Fatalf("reason mismatch: got=%q", got.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayoutFailurePolicy_DecideProviderFailure_UnknownCode(t *testing.T) {
|
||||||
|
policy := defaultPayoutFailurePolicy()
|
||||||
|
|
||||||
|
got := policy.decideProviderFailure("99999")
|
||||||
|
if got.Action != payoutFailureActionFail {
|
||||||
|
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionFail)
|
||||||
|
}
|
||||||
|
if got.Strategy != payoutRetryStrategyImmediate {
|
||||||
|
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
|
||||||
|
}
|
||||||
|
if got.Reason != "provider_code_99999_unknown" {
|
||||||
|
t.Fatalf("reason mismatch: got=%q", got.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayoutFailurePolicy_DecideTransportFailure(t *testing.T) {
|
||||||
|
policy := defaultPayoutFailurePolicy()
|
||||||
|
|
||||||
|
got := policy.decideTransportFailure()
|
||||||
|
if got.Action != payoutFailureActionRetry {
|
||||||
|
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionRetry)
|
||||||
|
}
|
||||||
|
if got.Strategy != payoutRetryStrategyImmediate {
|
||||||
|
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDelayForAttempt_ByStrategy(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
attempt uint32
|
||||||
|
strategy payoutRetryStrategy
|
||||||
|
wantDelay int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "immediate first attempt",
|
||||||
|
attempt: 1,
|
||||||
|
strategy: payoutRetryStrategyImmediate,
|
||||||
|
wantDelay: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delayed second attempt",
|
||||||
|
attempt: 2,
|
||||||
|
strategy: payoutRetryStrategyDelayed,
|
||||||
|
wantDelay: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status refresh third attempt",
|
||||||
|
attempt: 3,
|
||||||
|
strategy: payoutRetryStrategyStatusRefresh,
|
||||||
|
wantDelay: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if got := retryDelayForAttempt(tc.attempt, tc.strategy); got != tc.wantDelay {
|
||||||
|
t.Fatalf("delay mismatch: got=%d want=%d", got, tc.wantDelay)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func isFinalStatus(t *model.CardPayout) bool {
|
|||||||
|
|
||||||
func isFinalPayoutStatus(status model.PayoutStatus) bool {
|
func isFinalPayoutStatus(status model.PayoutStatus) bool {
|
||||||
switch status {
|
switch status {
|
||||||
case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
|
case model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -35,6 +35,8 @@ func toOpStatus(t *model.CardPayout) (rail.OperationResult, error) {
|
|||||||
switch t.Status {
|
switch t.Status {
|
||||||
case model.PayoutStatusFailed:
|
case model.PayoutStatusFailed:
|
||||||
return rail.OperationResultFailed, nil
|
return rail.OperationResultFailed, nil
|
||||||
|
case model.PayoutStatusNeedsAttention:
|
||||||
|
return rail.OperationResultFailed, nil
|
||||||
case model.PayoutStatusSuccess:
|
case model.PayoutStatusSuccess:
|
||||||
return rail.OperationResultSuccess, nil
|
return rail.OperationResultSuccess, nil
|
||||||
case model.PayoutStatusCancelled:
|
case model.PayoutStatusCancelled:
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/payments/rail"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsFinalPayoutStatus_NeedsAttentionIsFinal(t *testing.T) {
|
||||||
|
if !isFinalPayoutStatus(model.PayoutStatusNeedsAttention) {
|
||||||
|
t.Fatal("expected needs_attention to be final")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToOpStatus_NeedsAttentionMapsToFailed(t *testing.T) {
|
||||||
|
status, err := toOpStatus(&model.CardPayout{Status: model.PayoutStatusNeedsAttention})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("toOpStatus returned error: %v", err)
|
||||||
|
}
|
||||||
|
if status != rail.OperationResultFailed {
|
||||||
|
t.Fatalf("unexpected operation result: got=%q want=%q", status, rail.OperationResultFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,25 +166,16 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiResp.Operation.RequestID != "" {
|
result.ProviderRequestID = providerRequestID(apiResp)
|
||||||
result.ProviderRequestID = apiResp.Operation.RequestID
|
result.ProviderStatus = providerStatus(apiResp)
|
||||||
} else if apiResp.RequestID != "" {
|
|
||||||
result.ProviderRequestID = apiResp.RequestID
|
|
||||||
}
|
|
||||||
result.ProviderStatus = strings.TrimSpace(apiResp.Status)
|
|
||||||
if result.ProviderStatus == "" {
|
|
||||||
result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.Accepted {
|
errorCode, errorMessage := providerError(apiResp)
|
||||||
result.ErrorCode = apiResp.Code
|
if !result.Accepted || isProviderStatusError(result.ProviderStatus) {
|
||||||
if result.ErrorCode == "" {
|
result.ErrorCode = errorCode
|
||||||
|
if !result.Accepted && result.ErrorCode == "" {
|
||||||
result.ErrorCode = http.StatusText(resp.StatusCode)
|
result.ErrorCode = http.StatusText(resp.StatusCode)
|
||||||
}
|
}
|
||||||
result.ErrorMessage = apiResp.Message
|
result.ErrorMessage = errorMessage
|
||||||
if result.ErrorMessage == "" {
|
|
||||||
result.ErrorMessage = apiResp.Operation.Message
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logger.Info("Monetix tokenization response",
|
c.logger.Info("Monetix tokenization response",
|
||||||
@@ -288,25 +279,16 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiResp.Operation.RequestID != "" {
|
result.ProviderRequestID = providerRequestID(apiResp)
|
||||||
result.ProviderRequestID = apiResp.Operation.RequestID
|
result.ProviderStatus = providerStatus(apiResp)
|
||||||
} else if apiResp.RequestID != "" {
|
|
||||||
result.ProviderRequestID = apiResp.RequestID
|
|
||||||
}
|
|
||||||
result.ProviderStatus = strings.TrimSpace(apiResp.Status)
|
|
||||||
if result.ProviderStatus == "" {
|
|
||||||
result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.Accepted {
|
errorCode, errorMessage := providerError(apiResp)
|
||||||
result.ErrorCode = apiResp.Code
|
if !result.Accepted || isProviderStatusError(result.ProviderStatus) {
|
||||||
if result.ErrorCode == "" {
|
result.ErrorCode = errorCode
|
||||||
|
if !result.Accepted && result.ErrorCode == "" {
|
||||||
result.ErrorCode = http.StatusText(resp.StatusCode)
|
result.ErrorCode = http.StatusText(resp.StatusCode)
|
||||||
}
|
}
|
||||||
result.ErrorMessage = apiResp.Message
|
result.ErrorMessage = errorMessage
|
||||||
if result.ErrorMessage == "" {
|
|
||||||
result.ErrorMessage = apiResp.Operation.Message
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if responseLog != nil {
|
if responseLog != nil {
|
||||||
@@ -324,6 +306,32 @@ func normalizeExpiryYear(year int) int {
|
|||||||
return year
|
return year
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func providerRequestID(resp APIResponse) string {
|
||||||
|
return firstNonEmpty(resp.Operation.RequestID, resp.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerStatus(resp APIResponse) string {
|
||||||
|
return firstNonEmpty(resp.Status, resp.Operation.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerError(resp APIResponse) (code, message string) {
|
||||||
|
return firstNonEmpty(resp.Code, resp.Operation.Code), firstNonEmpty(resp.Message, resp.Operation.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProviderStatusError(status string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(status), "error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeRequestExpiryYear(req any) {
|
func normalizeRequestExpiryYear(req any) {
|
||||||
switch r := req.(type) {
|
switch r := req.(type) {
|
||||||
case *CardPayoutRequest:
|
case *CardPayoutRequest:
|
||||||
|
|||||||
@@ -175,6 +175,99 @@ func TestSendCardPayout_HTTPError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendCardPayout_HTTPAcceptedBodyErrorStillAccepted(t *testing.T) {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
body := `{"status":"error","code":"3062","message":"Payment details not received"}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
BaseURL: "https://monetix.test",
|
||||||
|
SecretKey: "secret",
|
||||||
|
}
|
||||||
|
client := NewClient(cfg, httpClient, zap.NewNop())
|
||||||
|
|
||||||
|
req := CardPayoutRequest{
|
||||||
|
General: General{ProjectID: 1, PaymentID: "payout-1"},
|
||||||
|
Customer: Customer{
|
||||||
|
ID: "cust-1",
|
||||||
|
FirstName: "Jane",
|
||||||
|
LastName: "Doe",
|
||||||
|
IP: "203.0.113.10",
|
||||||
|
},
|
||||||
|
Payment: Payment{Amount: 1000, Currency: "RUB"},
|
||||||
|
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.CreateCardPayout(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Accepted {
|
||||||
|
t.Fatalf("expected accepted response")
|
||||||
|
}
|
||||||
|
if result.ProviderStatus != "error" {
|
||||||
|
t.Fatalf("expected provider status error, got %q", result.ProviderStatus)
|
||||||
|
}
|
||||||
|
if result.ErrorCode != "3062" {
|
||||||
|
t.Fatalf("expected error code %q, got %q", "3062", result.ErrorCode)
|
||||||
|
}
|
||||||
|
if result.ErrorMessage != "Payment details not received" {
|
||||||
|
t.Fatalf("expected error message, got %q", result.ErrorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendCardPayout_HTTPErrorFallsBackToOperationCode(t *testing.T) {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
body := `{"operation":{"code":"3061","message":"Transaction not found"}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusBadRequest,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
BaseURL: "https://monetix.test",
|
||||||
|
SecretKey: "secret",
|
||||||
|
}
|
||||||
|
client := NewClient(cfg, httpClient, zap.NewNop())
|
||||||
|
|
||||||
|
req := CardPayoutRequest{
|
||||||
|
General: General{ProjectID: 1, PaymentID: "payout-1"},
|
||||||
|
Customer: Customer{
|
||||||
|
ID: "cust-1",
|
||||||
|
FirstName: "Jane",
|
||||||
|
LastName: "Doe",
|
||||||
|
IP: "203.0.113.10",
|
||||||
|
},
|
||||||
|
Payment: Payment{Amount: 1000, Currency: "RUB"},
|
||||||
|
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.CreateCardPayout(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if result.Accepted {
|
||||||
|
t.Fatalf("expected rejected response")
|
||||||
|
}
|
||||||
|
if result.ErrorCode != "3061" {
|
||||||
|
t.Fatalf("expected error code %q, got %q", "3061", result.ErrorCode)
|
||||||
|
}
|
||||||
|
if result.ErrorMessage != "Transaction not found" {
|
||||||
|
t.Fatalf("expected error message, got %q", result.ErrorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type errorReadCloser struct {
|
type errorReadCloser struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const (
|
|||||||
PayoutStatusProcessing PayoutStatus = "processing" // we are working on it
|
PayoutStatusProcessing PayoutStatus = "processing" // we are working on it
|
||||||
PayoutStatusWaiting PayoutStatus = "waiting" // waiting external world
|
PayoutStatusWaiting PayoutStatus = "waiting" // waiting external world
|
||||||
|
|
||||||
PayoutStatusSuccess PayoutStatus = "success" // final success
|
PayoutStatusSuccess PayoutStatus = "success" // final success
|
||||||
PayoutStatusFailed PayoutStatus = "failed" // final failure
|
PayoutStatusFailed PayoutStatus = "failed" // final failure
|
||||||
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
|
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
|
||||||
|
PayoutStatusNeedsAttention PayoutStatus = "needs_attention" // final, manual review required
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user