Apply second-round review fixes: idempotency-key length caps, stable-sentinel card keys, test-isolation, naming

Money-safety idempotency hardening (I1, wide):
- validate:"max=45" on CreateTerminalPayment/BookingPayment/Refund/Tip/
  BuyGiftCard idempotency keys (all feed Square's 45-char /v2/payments,
  /v2/refunds, /v2/cards caps); BuyGiftCard corrected from a wrongly-loose
  max=64. Till keeps max=64 (its key also feeds the 64-char terminal-checkout
  endpoint).
- Explicit 45-char guard in RefundPayment: the one handler that decodes
  RefundRequest without running the struct validator, so the tag alone was
  inert; a longer key would 400 at Square and be misclassified as a
  definitive refund decline.
- New TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers covers all
  six endpoints (terminal saved-card, booking, tip, gift-card, till, refund).

Stable-sentinel card identity in idempotency keys (C1, wide):
- BookingFlow deposit key now uses the 'new-card' sentinel instead of
  embedding the cnon: nonce (matches UserPaymentModal/account). A re-tokenize
  after a spent nonce no longer regenerates the key, closing a lost-response
  double-charge window.
- TipPayment + UserBookingModal tip keys now include card identity
  (selectedCardId || 'new-card'); previously keyed on amount only, so a
  same-amount tip on a DIFFERENT card reused the key and deduped a distinct
  charge. Resets cleared in every success/close path.

Test isolation (R1): TestRefund_PendingResume_NewKeyAfterModalReopen no
longer t.Parallel — it swaps the package-level SquareClient mid-test and a
concurrent parallel test could observe the swapped instance.

Naming/quality (M1/M2/M4): resolveChargeSource local renamed savedRowID (was
shadowing the cardID *string parameter); BuyGiftCard fallback prefix
"till-" -> "gc-"; saved-card terminal response key "checkout_id" -> "payment_id"
(it holds a DB payment row, not a Square checkout) with matching frontend
fallback. README maintenance-job count corrected 24 -> 25.

Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0
errors/warnings; production build succeeds.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 63226debb7
commit 7f1c649f1e
11 changed files with 149 additions and 23 deletions
+17 -7
View File
@@ -42,8 +42,9 @@ type CreateTerminalPaymentRequest struct {
// one booking (e.g. a second £50 'full' charge for a second service) get
// different keys and never collapse on the deterministic fallback key.
// When absent, the handler falls back to the deterministic booking+type+
// amount+card key for no-client-key retry safety.
IdempotencyKey string `json:"idempotency_key,omitempty"`
// amount+card key for no-client-key retry safety. Cap ≤45 (Square's
// idempotency-key limit for /v2/payments — this key feeds CreatePayment).
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
}
type CreateBookingPaymentRequest struct {
@@ -52,7 +53,7 @@ type CreateBookingPaymentRequest struct {
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key" validate:"required"`
IdempotencyKey string `json:"idempotency_key" validate:"required,max=45"`
VerificationToken *string `json:"verification_token,omitempty"`
}
@@ -63,8 +64,8 @@ type RefundRequest struct {
// same amount against the same payment must not collide on the default
// amount-derived key (the dedup lookup would swallow the second refund).
// The frontend sends a UUID generated per refund attempt and reuses it on
// retry, mirroring the tip-flow pattern.
IdempotencyKey string `json:"idempotency_key,omitempty"`
// retry, mirroring the tip-flow pattern. Cap ≤45 (Square's /v2/refunds limit).
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
}
type CreateTipPaymentRequest struct {
@@ -72,7 +73,7 @@ type CreateTipPaymentRequest struct {
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
VerificationToken *string `json:"verification_token,omitempty"`
}
@@ -684,7 +685,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// Return the card details the frontend reads for the success state
// (MINOR-R2) — CheckoutResponse alone leaves card_brand/card_last4 blank.
if err := json.NewEncoder(w).Encode(map[string]any{
"checkout_id": paymentID,
"payment_id": paymentID,
"status": "COMPLETED",
"card_brand": paymentResult.CardBrand,
"card_last4": paymentResult.CardLast4,
@@ -1983,6 +1984,15 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
return
}
// Client-supplied idempotency key feeds Square /v2/refunds (45-char cap).
// This handler decodes into RefundRequest without running the struct
// validator, so enforce the limit explicitly — a longer key would 400 at
// Square and be misclassified as a definitive refund decline.
if len(req.IdempotencyKey) > 45 {
http.Error(w, "Invalid request: idempotency_key exceeds 45 characters", http.StatusBadRequest)
return
}
service := NewPaymentService()
payment, err := service.GetPaymentByID(r.Context(), paymentID)