Files
sendico/api/gateway/mntx/internal/service/gateway/payout_failure_policy.go
2026-03-06 12:14:32 +01:00

291 lines
7.4 KiB
Go

package gateway
import (
"sort"
"strconv"
"strings"
)
type payoutFailureAction int
const (
payoutFailureActionFail payoutFailureAction = iota + 1
payoutFailureActionRetry
)
type payoutRetryStrategy int
const (
payoutRetryStrategyImmediate payoutRetryStrategy = iota + 1
payoutRetryStrategyDelayed
payoutRetryStrategyStatusRefresh
)
type payoutFailureDecision struct {
Action payoutFailureAction
Strategy payoutRetryStrategy
Reason string
}
type payoutFailurePolicy struct {
providerCodeStrategies map[string]payoutRetryStrategy
documentedProviderCodes map[string]struct{}
}
type retryCodeBucket struct {
strategy payoutRetryStrategy
retryable bool
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 {
normalized := normalizeProviderCode(code)
if normalized == "" {
return payoutFailureDecision{
Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_failure",
}
}
if strategy, ok := p.providerCodeStrategies[normalized]; ok {
return payoutFailureDecision{
Action: payoutFailureActionRetry,
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{
Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_code_" + normalized + "_unknown",
}
}
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
return payoutFailureDecision{
Action: payoutFailureActionRetry,
Strategy: payoutRetryStrategyImmediate,
Reason: "transport_failure",
}
}
func payoutFailureReason(code, message string) string {
cleanCode := strings.TrimSpace(code)
cleanMessage := strings.TrimSpace(message)
switch {
case cleanCode != "" && cleanMessage != "":
return cleanCode + ": " + cleanMessage
case cleanCode != "":
return cleanCode
default:
return cleanMessage
}
}
func retryDelayForAttempt(attempt uint32, strategy payoutRetryStrategy) int {
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 {
case attempt <= 1:
return 5
case attempt == 2:
return 15
case attempt == 3:
return 30
default:
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"
}
}