301 lines
7.8 KiB
Go
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
|
|
}
|