Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds: - F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks - F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back - F3: post-start online overflow carved as a tip record (mirrors terminal split builder) - A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError) - A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications - A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check) - A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected - A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point) - A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface - Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications) - Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments - Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files) - Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status) - gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys) All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
76 lines
3.2 KiB
Go
76 lines
3.2 KiB
Go
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
|
|
// "<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])
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|