PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated stored-credential charges; a merchant-side 2FA check cannot legally substitute for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent). - payments/twofa.go: the homegrown 2FA fallback for token-less saved-card charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only: a non-empty Square verification_token (charge surfaces, token forwarded to Square) skips the gate; anything else is refused 402 verification_required. enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs). - New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces where the token IS forwarded to Square (charge — Square validates it) from card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token must NOT skip the save gate, auth-F1). - SCA tokenize-result wire contract (C1): a saved card charged with a fresh one-time tokenize-result sends the token as the charge SOURCE (new_card_token -> source_id) alongside saved_card_id, never a separate verification_token. resolveChargeSource resolves the saved-card branch FIRST (customer from the card row, token as source) so combined token+card requests are SCA-clean. - C6 consent fields (consent_version / consent_accepted) added to the booking/ tip/till/gift-card charge requests, enforced server-side before any fallback charge could reach Square and recorded on the 2fa_fallback_charge audit row; logVerificationTokenProvenance traces minted tokens to their charge. - user 2FA issuance gate refactored into pure build-agnostic functions (twoFAPepperConfigured / twoFADeliveryChannelConfigured / twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and .env.example entry removed; startup posture notes updated. - Test coverage: fail-closed 2FA production gates (pepper/delivery), token validation on save vs charge surfaces, completion idempotency, idempotency key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
119 lines
5.5 KiB
Go
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 os.Getenv("SQUARE_ENVIRONMENT") {
|
|
case "mock", "dev", "development", "test":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|