package payments import ( "crypto/sha256" "encoding/hex" "fmt" "os" "strings" "crussell/internal/square" ) // maxIdempotencyKeyLength caps idempotency keys at Square's /v2/payments limit // (45 chars). The same key is replayed to CreatePayment, so the stricter // 45-char cap applies even where a destination (e.g. CreateCheckout) allows 64. // Client-supplied keys are validated against it ("omitempty,max=45") and // server-derived keys are truncated to it via truncateIdempotencyKey. // Aliased from the square package — the client to Square, whose limit this is — // so there is a single source of the constant, not a per-package drift surface. const maxIdempotencyKeyLength = square.MaxIdempotencyKeyLength // truncateIdempotencyKey applies the deterministic >45-char sha256 truncation // shared by the derive* idempotency-key helpers: a candidate longer than // maxIdempotencyKeyLength is hashed with SHA-256 and returned as // "-", which stays within Square's // 45-char /v2/payments limit. The hash is deterministic, so identical // candidates always truncate to the same key — a lost-response retry re-derives // the same truncated key and Square dedups the charge. Candidates at or under // the limit are returned verbatim. func truncateIdempotencyKey(prefix, candidate string) string { if len(candidate) <= maxIdempotencyKeyLength { return candidate } sum := sha256.Sum256([]byte(candidate)) return prefix + "-" + hex.EncodeToString(sum[:16]) } // nextIdempotencyCandidate returns the idempotency-key candidate for slot // sequence seq: the base key itself at seq 0, or "base-seq" at seq >= 1, then // truncated via truncateIdempotencyKey so the final key stays inside Square's // 45-char /v2/payments limit. The truncation prefix is derived from the base // key ("gc-..." -> "gc", "till-..." -> "till") so the truncated form keeps the // caller's namespace prefix. The slot-scan callers (scanTillIdempotencyKeySlot, // deriveGiftCardIdempotencyKey) use this under their advisory lock so the // scan-and-insert sequence is stable across retries. func nextIdempotencyCandidate(base string, seq int) string { candidate := base if seq > 0 { candidate = fmt.Sprintf("%s-%d", base, seq) } prefix := base if i := strings.IndexByte(base, '-'); i > 0 { prefix = base[:i] } return truncateIdempotencyKey(prefix, candidate) } // IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects // the dev/mock Square stack. Only these exact values are treated as dev; an // empty or unknown value is NOT dev (fail-closed), because in production an // unset/mistyped env var must never bypass the 2FA gate or decrypt/encrypt // snapshot expectations (A9). It lives here — the neutral idempotency helper // file — because it gates far more than 2FA: snapshot encryption // (charge_helpers.go), the sweep's replay checks and snapshot decryption // (sweep.go), the till snapshot refresh (till.go), the gift-card reuse // snapshot handling (giftcards.go), and main.go's startup warnings. The // exported name is stable for main.go; in-package callers use it directly. func IsExplicitDevOrMockEnv() bool { switch os.Getenv("SQUARE_ENVIRONMENT") { case "mock", "dev", "development", "test": return true default: return false } }