package payments import ( "context" "crypto/sha256" "encoding/hex" "fmt" "os" "strconv" "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]) } // deriveRefundIdempotencyKey returns the deterministic SERVER-SIDE idempotency // key for a refund issued WITHOUT a client-supplied key (M1): a retry of the // same logical refund re-derives the SAME key, so Square's idempotency dedup // returns the original refund instead of minting a SECOND Square refund — even // after the sweep has resolved the first attempt (a client keyed to // (payment_id, amount, refund type) can never regenerate the fresh random // suffix the old no-key fallback used). The key is derived ONLY from stable // request fields — never a random value — and routed through // truncateIdempotencyKey so an over-length candidate stays deterministic and // inside Square's 45-char /v2/refunds limit (preserving its semantics). The // distinct refundType (e.g. "manual" vs "cancellation") keeps a partial refund // of the same payment+amount distinct from a cancellation refund of the same // size, and the paymentID prefix prevents cross-payment collisions. func deriveRefundIdempotencyKey(paymentID string, amountPence int64, refundType string) string { candidate := paymentID + "-refund-" + strconv.FormatInt(amountPence, 10) + "-" + refundType return truncateIdempotencyKey("refund", candidate) } // 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) } // scanIdempotencySlot iterates the candidate sequence for baseKey (seq 0, 1, // 2, ...) until it finds a slot NOT occupied by a terminal row, returning the // first free candidate. occupied reports whether the candidate is taken; the // caller supplies the table-specific occupancy check. Shared by the gift-card // purchase key derivation (deriveGiftCardIdempotencyKey) and the till-sale key // derivation (scanTillIdempotencyKeySlot), which must agree on the // completed/failed-occupies, pending-never-occupies rule so a lost-response // retry reuses the same key instead of minting a second charge. Must be called // under the caller's advisory lock so the scan-and-insert races no concurrent // identical request. func scanIdempotencySlot(ctx context.Context, baseKey string, occupied func(candidate string) (bool, error)) (string, error) { for seq := 0; ; seq++ { candidate := nextIdempotencyCandidate(baseKey, seq) isOccupied, err := occupied(candidate) if err != nil { return "", err } if !isOccupied { return candidate, nil } } } // 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 strings.ToLower(strings.TrimSpace(os.Getenv("SQUARE_ENVIRONMENT"))) { case "mock", "dev", "development", "test": return true default: return false } }