Files
Crussell/backend/handlers/payments/idempotency_helpers.go
T
popertotsandSisyphus 9a12a2d886 fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

119 lines
5.5 KiB
Go

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
// "<prefix>-<hex of the first 16 hash bytes>", 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
}
}