Files
sendico/api/gateway/chsettle/internal/service/gateway/scenario_simulator.go
Stephan D e77d1ab793 linting
2026-03-10 12:31:09 +01:00

301 lines
7.8 KiB
Go

package gateway
import (
"hash/fnv"
"strconv"
"strings"
"time"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
const (
scenarioMetadataKey = "chsettle_scenario"
scenarioMetadataAliasKey = "scenario"
)
type settlementScenario struct {
Name string
InitialStatus storagemodel.PaymentStatus
FinalStatus storagemodel.PaymentStatus
FinalDelay time.Duration
FailureReason string
}
type settlementScenarioTrace struct {
Source string
OverrideRaw string
OverrideNormalized string
AmountRaw string
AmountCurrency string
BucketSlot int
}
var scenarioFastSuccess = settlementScenario{
Name: "fast_success",
InitialStatus: storagemodel.PaymentStatusSuccess,
}
var scenarioSlowSuccess = settlementScenario{
Name: "slow_success",
InitialStatus: storagemodel.PaymentStatusWaiting,
FinalStatus: storagemodel.PaymentStatusSuccess,
FinalDelay: 30 * time.Second,
}
var scenarioFailImmediate = settlementScenario{
Name: "fail_immediate",
InitialStatus: storagemodel.PaymentStatusFailed,
FailureReason: "simulated_fail_immediate",
}
var scenarioFailTimeout = settlementScenario{
Name: "fail_timeout",
InitialStatus: storagemodel.PaymentStatusWaiting,
FinalStatus: storagemodel.PaymentStatusFailed,
FinalDelay: 45 * time.Second,
FailureReason: "simulated_fail_timeout",
}
var scenarioStuckPending = settlementScenario{
Name: "stuck_pending",
InitialStatus: storagemodel.PaymentStatusWaiting,
}
var scenarioRetryThenSuccess = settlementScenario{
Name: "retry_then_success",
InitialStatus: storagemodel.PaymentStatusProcessing,
FinalStatus: storagemodel.PaymentStatusSuccess,
FinalDelay: 25 * time.Second,
}
var scenarioWebhookDelayedSuccess = settlementScenario{
Name: "webhook_delayed_success",
InitialStatus: storagemodel.PaymentStatusWaiting,
FinalStatus: storagemodel.PaymentStatusSuccess,
FinalDelay: 60 * time.Second,
}
var scenarioSlowThenFail = settlementScenario{
Name: "slow_then_fail",
InitialStatus: storagemodel.PaymentStatusProcessing,
FinalStatus: storagemodel.PaymentStatusFailed,
FinalDelay: 75 * time.Second,
FailureReason: "simulated_slow_then_fail",
}
var scenarioPartialProgressStuck = settlementScenario{
Name: "partial_progress_stuck",
InitialStatus: storagemodel.PaymentStatusProcessing,
}
func resolveSettlementScenario(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) settlementScenario {
scenario, _ := resolveSettlementScenarioWithTrace(idempotencyKey, amount, metadata)
return scenario
}
func resolveSettlementScenarioWithTrace(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) (settlementScenario, settlementScenarioTrace) {
trace := settlementScenarioTrace{
BucketSlot: -1,
}
if amount != nil {
trace.AmountRaw = strings.TrimSpace(amount.Amount)
trace.AmountCurrency = strings.TrimSpace(amount.Currency)
}
overrideScenario, overrideRaw, overrideNormalized, overrideApplied := parseScenarioOverride(metadata)
if overrideRaw != "" {
trace.OverrideRaw = overrideRaw
trace.OverrideNormalized = overrideNormalized
}
if overrideApplied {
trace.Source = "explicit_override"
return overrideScenario, trace
}
slot, ok := amountModuloSlot(amount)
if ok {
if trace.OverrideRaw != "" {
trace.Source = "invalid_override_amount_bucket"
} else {
trace.Source = "amount_bucket"
}
trace.BucketSlot = slot
return scenarioBySlot(slot, idempotencyKey), trace
}
slot = hashModulo(idempotencyKey, 1000)
if trace.OverrideRaw != "" {
trace.Source = "invalid_override_idempotency_hash_bucket"
} else {
trace.Source = "idempotency_hash_bucket"
}
trace.BucketSlot = slot
return scenarioBySlot(slot, idempotencyKey), trace
}
func parseScenarioOverride(metadata map[string]string) (settlementScenario, string, string, bool) {
if len(metadata) == 0 {
return settlementScenario{}, "", "", false
}
overrideRaw := strings.TrimSpace(metadata[scenarioMetadataKey])
if overrideRaw == "" {
overrideRaw = strings.TrimSpace(metadata[scenarioMetadataAliasKey])
}
if overrideRaw == "" {
return settlementScenario{}, "", "", false
}
scenario, normalized, ok := scenarioByName(overrideRaw)
return scenario, overrideRaw, normalized, ok
}
func scenarioByName(value string) (settlementScenario, string, bool) {
key := normalizeScenarioName(value)
switch key {
case "fast_success", "success_fast", "instant_success":
return scenarioFastSuccess, key, true
case "slow_success", "success_slow":
return scenarioSlowSuccess, key, true
case "fail_immediate", "immediate_fail", "failed":
return scenarioFailImmediate, key, true
case "fail_timeout", "timeout_fail":
return scenarioFailTimeout, key, true
case "stuck", "stuck_pending", "pending_stuck":
return scenarioStuckPending, key, true
case "retry_then_success":
return scenarioRetryThenSuccess, key, true
case "webhook_delayed_success":
return scenarioWebhookDelayedSuccess, key, true
case "slow_then_fail":
return scenarioSlowThenFail, key, true
case "partial_progress_stuck":
return scenarioPartialProgressStuck, key, true
case "chaos", "chaos_random_seeded":
return scenarioBySlot(950, ""), key, true
default:
return settlementScenario{}, key, false
}
}
func normalizeScenarioName(value string) string {
key := strings.ToLower(strings.TrimSpace(value))
key = strings.ReplaceAll(key, "-", "_")
return key
}
func scenarioBySlot(slot int, seed string) settlementScenario {
switch {
case slot < 100:
return scenarioFastSuccess
case slot < 200:
return scenarioSlowSuccess
case slot < 300:
return scenarioFailImmediate
case slot < 400:
return scenarioFailTimeout
case slot < 500:
return scenarioStuckPending
case slot < 600:
return scenarioRetryThenSuccess
case slot < 700:
return scenarioWebhookDelayedSuccess
case slot < 800:
return scenarioSlowThenFail
case slot < 900:
return scenarioPartialProgressStuck
default:
return chaosScenario(seed)
}
}
func chaosScenario(seed string) settlementScenario {
choices := []settlementScenario{
scenarioFastSuccess,
scenarioSlowSuccess,
scenarioFailImmediate,
scenarioFailTimeout,
scenarioStuckPending,
scenarioSlowThenFail,
}
idx := hashModulo(seed, len(choices))
return choices[idx]
}
func amountModuloSlot(amount *paymenttypes.Money) (int, bool) {
if amount == nil {
return 0, false
}
raw := strings.TrimSpace(amount.Amount)
if raw == "" {
return 0, false
}
sign := 1
raw = strings.TrimPrefix(raw, "+")
if strings.HasPrefix(raw, "-") {
sign = -1
raw = strings.TrimPrefix(raw, "-")
}
parts := strings.SplitN(raw, ".", 3)
if len(parts) == 0 || len(parts) > 2 {
return 0, false
}
whole := parts[0]
if whole == "" || !digitsOnly(whole) {
return 0, false
}
frac := "00"
if len(parts) == 2 {
f := parts[1]
if f == "" || !digitsOnly(f) {
return 0, false
}
if len(f) >= 2 {
frac = f[:2]
} else {
frac = f + "0"
}
}
wholeMod := digitsMod(whole, 10)
fracVal, _ := strconv.Atoi(frac)
slot := (wholeMod*100 + fracVal) % 1000
if sign < 0 {
slot = (-slot + 1000) % 1000
}
return slot, true
}
func digitsOnly(value string) bool {
if value == "" {
return false
}
for i := 0; i < len(value); i++ {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return true
}
func digitsMod(value string, mod int) int {
if mod <= 0 {
return 0
}
result := 0
for i := 0; i < len(value); i++ {
digit := int(value[i] - '0')
result = (result*10 + digit) % mod
}
return result
}
func hashModulo(input string, mod int) int {
if mod <= 0 {
return 0
}
h := fnv.New32a()
_, _ = h.Write([]byte(strings.TrimSpace(input)))
return int(h.Sum32()) % mod
}
func (s settlementScenario) delayedTransitionEnabled() bool {
return s.FinalStatus != "" && s.FinalDelay > 0
}