mntx error codes update #684

Merged
tech merged 1 commits from mntx-683 into main 2026-03-06 12:35:10 +00:00
12 changed files with 1405 additions and 118 deletions

View File

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

View File

@@ -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"

View File

@@ -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:

View File

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

View File

@@ -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"
}
}

View File

@@ -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",
}

View File

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

View File

@@ -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:

View File

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

View File

@@ -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:

View File

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

View File

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